diff --git a/apps/web/package.json b/apps/web/package.json index 1dea5e859..46c04e555 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -42,6 +42,7 @@ "flatpickr": "^4.6.13", "formik": "^2.4.6", "framer-motion": "^12.12.1", + "graphql-request": "^7.1.2", "ioredis": "^5.8.1", "next": "^15.5.9", "nextjs-progressbar": "^0.0.16", @@ -49,6 +50,7 @@ "react": "^19.2.1", "react-dom": "^19.2.1", "react-mde": "^11.5.0", + "remove-markdown": "^0.6.3", "sablier": "^2.0.1", "sharp": "^0.34.2", "swr": "^2.3.3", diff --git a/apps/web/public/builderlogo.png b/apps/web/public/builderlogo.png new file mode 100644 index 000000000..1b06d6e5c Binary files /dev/null and b/apps/web/public/builderlogo.png differ diff --git a/apps/web/public/noggles-square-dark.svg b/apps/web/public/noggles-square-dark.svg new file mode 100644 index 000000000..2eae203f8 --- /dev/null +++ b/apps/web/public/noggles-square-dark.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/web/public/noggles-square.svg b/apps/web/public/noggles-square.svg new file mode 100644 index 000000000..c4979bf04 --- /dev/null +++ b/apps/web/public/noggles-square.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/web/src/modules/about/AboutPage.css.ts b/apps/web/src/modules/about/AboutPage.css.ts new file mode 100644 index 000000000..3c9fec2c0 --- /dev/null +++ b/apps/web/src/modules/about/AboutPage.css.ts @@ -0,0 +1,1593 @@ +import { theme } from '@buildeross/zord' +import { globalStyle, keyframes, style } from '@vanilla-extract/css' + +const focusRing = { + outline: `2px solid ${theme.colors.focusRing}`, + outlineOffset: '2px', +} + +const standardBorder = `2px solid ${theme.colors.border}` +const standardBorderThin = `1px solid ${theme.colors.border}` +const softBlueBackground = `color-mix(in srgb, ${theme.colors.focusRing} 14%, ${theme.colors.background1})` +const softBlueBorder = theme.colors.focusRing + +const marqueeScroll = keyframes({ + '0%': { + transform: 'translateX(0)', + }, + '100%': { + transform: 'translateX(-50%)', + }, +}) + +export const page = style({ + width: '100%', + padding: '48px 16px 96px', + boxSizing: 'border-box', + background: theme.colors.background1, + overflowX: 'clip', + '@media': { + 'screen and (min-width: 768px)': { + padding: '64px 24px 120px', + }, + }, +}) + +export const container = style({ + maxWidth: '1180px', + margin: '0 auto', + width: '100%', + minWidth: 0, +}) + +export const centeredImageWrap = style({ + display: 'flex', + justifyContent: 'center', +}) + +export const centeredImage = style({ + width: '280px', + maxWidth: '100%', + height: 'auto', + display: 'block', + selectors: { + 'html[data-theme-mode="dark"] &': { + filter: 'invert(1)', + }, + }, +}) + +export const heroVennWrap = style({ + width: '100%', + height: '100%', + minHeight: '220px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '12px', +}) + +export const heroVennImage = style({ + width: '100%', + maxWidth: '260px', + height: 'auto', + display: 'block', +}) + +const darkModeSelector = 'html[data-theme-mode="dark"] &' + +export const aboutLightOnly = style({ + display: 'block !important', + selectors: { + [darkModeSelector]: { + display: 'none !important', + }, + }, +}) + +export const aboutDarkOnly = style({ + display: 'none !important', + selectors: { + [darkModeSelector]: { + display: 'block !important', + }, + }, +}) + +export const section = style({ + marginTop: '72px', + '@media': { + 'screen and (min-width: 768px)': { + marginTop: '96px', + }, + }, +}) + +export const sectionHeader = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', + marginBottom: '28px', + maxWidth: '720px', +}) + +export const eyebrow = style({ + display: 'inline-flex', + width: 'fit-content', + borderRadius: '999px', + padding: '4px 10px', + background: theme.colors.background2, + border: standardBorderThin, + fontSize: '12px', + lineHeight: '16px', + letterSpacing: '0.04em', + textTransform: 'uppercase', + color: theme.colors.text3, + fontWeight: 700, +}) + +export const sectionTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '28px', + lineHeight: 1.04, + whiteSpace: 'normal', + overflowWrap: 'anywhere', + color: theme.colors.text1, + '@media': { + 'screen and (min-width: 768px)': { + fontSize: '40px', + whiteSpace: 'nowrap', + overflowWrap: 'normal', + }, + }, +}) + +export const sectionTitleOnly = style({ + marginBottom: '12px', +}) + +export const sectionCopy = style({ + fontSize: '16px', + lineHeight: 1.55, + color: theme.colors.text3, +}) + +export const introCopyNoWrap = style({ + fontSize: '16px', + lineHeight: 1.55, + color: theme.colors.text3, + whiteSpace: 'normal', + '@media': { + 'screen and (min-width: 960px)': { + whiteSpace: 'nowrap', + }, + }, +}) + +export const hero = style({ + display: 'grid', + gap: '28px', + alignItems: 'stretch', + '@media': { + 'screen and (min-width: 980px)': { + gridTemplateColumns: 'minmax(0, 1.1fr) minmax(360px, 0.9fr)', + gap: '40px', + }, + }, +}) + +export const heroCopy = style({ + display: 'flex', + flexDirection: 'column', + gap: '20px', + paddingTop: '8px', + minWidth: 0, +}) + +export const heroTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '36px', + lineHeight: 1, + letterSpacing: '-0.03em', + color: theme.colors.text1, + maxWidth: '760px', + overflowWrap: 'anywhere', + '@media': { + 'screen and (min-width: 768px)': { + fontSize: '52px', + }, + }, +}) + +export const heroText = style({ + maxWidth: '640px', + fontSize: '16px', + lineHeight: 1.6, + color: theme.colors.text3, + '@media': { + 'screen and (min-width: 768px)': { + fontSize: '18px', + lineHeight: 1.65, + }, + }, +}) + +export const heroHighlightList = style({ + display: 'grid', + gap: '10px', + margin: 0, + padding: 0, + listStyle: 'none', + marginTop: '-4px', +}) + +export const heroHighlight = style({ + display: 'flex', + alignItems: 'flex-start', + gap: '10px', + fontSize: '14px', + lineHeight: 1.5, + color: theme.colors.text2, +}) + +export const heroHighlightDot = style({ + width: '8px', + height: '8px', + borderRadius: '999px', + background: theme.colors.text1, + flexShrink: 0, + marginTop: '6px', +}) + +export const heroActions = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', + alignItems: 'stretch', + '@media': { + 'screen and (min-width: 640px)': { + flexDirection: 'row', + alignItems: 'center', + }, + }, +}) + +export const heroPanel = style({ + position: 'relative', + overflow: 'hidden', + borderRadius: '16px', + border: standardBorder, + background: theme.colors.background2, + padding: '14px', + minHeight: 'auto', + boxShadow: 'none', + '@media': { + 'screen and (min-width: 768px)': { + padding: '16px', + minHeight: '420px', + }, + }, +}) + +export const heroPanelGlow = style({ + position: 'absolute', + inset: 0, + pointerEvents: 'none', + background: 'transparent', +}) + +export const montageGrid = style({ + position: 'relative', + zIndex: 1, + display: 'grid', + gap: '12px', + gridTemplateColumns: '1fr', + gridTemplateAreas: '"primary" "side" "footer"', + '@media': { + 'screen and (min-width: 541px)': { + gridTemplateColumns: '1.05fr 0.95fr', + gridTemplateAreas: '"primary side" "footer footer"', + }, + }, +}) + +export const montageCard = style({ + borderRadius: '12px', + border: standardBorder, + background: theme.colors.background1, + padding: '16px', + boxShadow: 'none', + minWidth: 0, +}) + +export const montagePrimary = style({ + gridArea: 'primary', + minHeight: 'unset', +}) + +export const montageSide = style({ + gridArea: 'side', + minHeight: '180px', + '@media': { + 'screen and (min-width: 768px)': { + minHeight: '220px', + }, + }, +}) + +export const montageSecondary = style({ + gridArea: 'secondary', + minHeight: '150px', +}) + +export const montageFooter = style({ + gridArea: 'footer', + minHeight: '120px', + padding: '10px', + '@media': { + 'screen and (min-width: 768px)': { + minHeight: '180px', + }, + }, +}) + +export const logoMarquee = style({ + width: '100%', + height: '100%', + minHeight: '96px', + overflow: 'hidden', + borderRadius: '10px', + background: theme.colors.background1, + '@media': { + 'screen and (min-width: 768px)': { + minHeight: '160px', + }, + }, +}) + +export const logoMarqueeTrack = style({ + position: 'relative', + overflow: 'hidden', + width: '100%', + height: '100%', + selectors: { + '&::before': { + content: '', + position: 'absolute', + inset: 0, + background: + 'linear-gradient(90deg, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0) 10%, rgba(255, 255, 255, 0) 90%, rgba(255, 255, 255, 1) 100%)', + zIndex: 2, + pointerEvents: 'none', + }, + }, +}) + +export const logoMarqueeInner = style({ + display: 'flex', + alignItems: 'center', + gap: '12px', + width: 'max-content', + minWidth: '100%', + height: '100%', + padding: '14px 0', + animation: `${marqueeScroll} 28s linear infinite`, + '@media': { + 'screen and (min-width: 768px)': { + gap: '18px', + padding: '22px 0', + }, + }, +}) + +export const logoMarqueeItem = style({ + width: '64px', + height: '64px', + borderRadius: '12px', + overflow: 'hidden', + background: theme.colors.background1, + boxShadow: 'none', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + '@media': { + 'screen and (min-width: 768px)': { + width: '86px', + height: '86px', + borderRadius: '22px', + }, + }, +}) + +export const logoMarqueeImage = style({ + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', +}) + +export const montageLabel = style({ + fontSize: '12px', + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: theme.colors.text4, + fontWeight: 700, +}) + +export const montageValue = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '24px', + lineHeight: 1, + whiteSpace: 'pre-line', + color: theme.colors.text1, + '@media': { + 'screen and (min-width: 768px)': { + marginTop: '10px', + fontSize: '28px', + }, + }, +}) + +export const montageBody = style({ + marginTop: '10px', + fontSize: '14px', + lineHeight: 1.55, + color: theme.colors.text3, +}) + +export const daoMiniList = style({ + display: 'grid', + gap: '10px', + marginTop: '14px', +}) + +export const daoMiniCard = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '12px', + padding: '12px 14px', + borderRadius: '16px', + background: theme.colors.background2, +}) + +export const daoMiniAvatar = style({ + width: '40px', + height: '40px', + borderRadius: '14px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 700, + fontSize: '14px', + color: theme.colors.text1, +}) + +export const heroFooterStat = style({ + display: 'flex', + flexDirection: 'column', + gap: '6px', +}) + +export const heroFooterValue = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '24px', + color: theme.colors.text1, +}) + +export const statGrid = style({ + display: 'grid', + width: '100%', + gap: '16px', + gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', + '@media': { + 'screen and (min-width: 640px)': { + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + }, + 'screen and (min-width: 1080px)': { + gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', + }, + }, +}) + +export const statCard = style({ + position: 'relative', + overflow: 'hidden', + minHeight: '178px', + borderRadius: '12px', + border: standardBorder, + background: theme.colors.background1, + padding: '22px', + boxShadow: 'none', + transition: 'border-color 0.15s ease, background-color 0.15s ease', + selectors: { + '&:hover': { + borderColor: softBlueBorder, + backgroundColor: softBlueBackground, + }, + }, +}) + +export const statAccent = style({ + position: 'absolute', + top: '18px', + right: '18px', + width: '54px', + height: '54px', + borderRadius: '12px', + opacity: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '28px', + lineHeight: 1, +}) + +export const statAccentDao = style({ background: theme.colors.background2 }) +export const statAccentTreasury = style({ background: theme.colors.positive }) +export const statAccentAuction = style({ background: theme.colors.warning }) +export const statAccentProposal = style({ background: theme.colors.focusRing }) +export const statAccentMembers = style({ background: theme.colors.negative }) +export const statAccentTokens = style({ background: theme.colors.focusRing }) + +export const statLabel = style({ + position: 'relative', + zIndex: 1, + fontSize: '13px', + fontWeight: 700, + letterSpacing: '0.06em', + textTransform: 'uppercase', + color: theme.colors.text4, +}) + +export const statValue = style({ + position: 'relative', + zIndex: 1, + marginTop: '18px', + fontFamily: 'ptRoot, sans-serif', + fontSize: '36px', + lineHeight: 1, + color: theme.colors.text1, + '@media': { + 'screen and (min-width: 768px)': { + fontSize: '42px', + }, + }, +}) + +export const statDetail = style({ + position: 'relative', + zIndex: 1, + marginTop: '18px', + maxWidth: '28ch', + fontSize: '14px', + lineHeight: 1.55, + color: theme.colors.text3, +}) + +export const sectionTopRow = style({ + display: 'flex', + flexDirection: 'column', + gap: '18px', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: '28px', + '@media': { + 'screen and (min-width: 768px)': { + flexDirection: 'row', + alignItems: 'flex-end', + }, + }, +}) + +export const sectionInlineRow = style({ + display: 'flex', + flexDirection: 'column', + gap: '18px', + alignItems: 'flex-start', + justifyContent: 'space-between', + marginBottom: '28px', + '@media': { + 'screen and (min-width: 960px)': { + flexDirection: 'row', + alignItems: 'center', + }, + }, +}) + +export const sectionInlineCopy = style({ + maxWidth: '760px', + fontSize: '16px', + lineHeight: 1.55, + color: theme.colors.text3, + margin: 0, +}) + +export const tabs = style({ + display: 'flex', + flexWrap: 'wrap', + gap: '10px', + '@media': { + 'screen and (max-width: 520px)': { + width: '100%', + flexWrap: 'nowrap', + gap: '6px', + }, + }, +}) + +export const tabButton = style({ + minHeight: '42px', + borderRadius: '999px', + border: standardBorderThin, + background: theme.colors.background1, + padding: '10px 16px', + fontSize: '14px', + fontWeight: 700, + color: theme.colors.text1, + transition: 'background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease', + selectors: { + '&:hover': { + cursor: 'pointer', + borderColor: softBlueBorder, + backgroundColor: softBlueBackground, + }, + '&:focus-visible': focusRing, + }, + '@media': { + 'screen and (max-width: 520px)': { + flex: '1 1 0', + minWidth: 0, + minHeight: '38px', + padding: '8px 6px', + fontSize: '13px', + }, + }, +}) + +export const activeTabButton = style({ + background: softBlueBackground, + borderColor: softBlueBorder, + color: theme.colors.text1, + selectors: { + '&:hover': { + borderColor: softBlueBorder, + backgroundColor: softBlueBackground, + color: theme.colors.text1, + }, + }, +}) + +export const daoGrid = style({ + display: 'grid', + width: '100%', + gap: '16px', + gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', + '@media': { + 'screen and (min-width: 700px)': { + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + }, + 'screen and (min-width: 1080px)': { + gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', + }, + }, +}) + +export const mobileStatsHeading = style({ + display: 'block', + margin: '28px 0 14px', + fontFamily: 'ptRoot, sans-serif', + fontSize: '24px', + lineHeight: 1.1, + color: theme.colors.text1, + '@media': { + 'screen and (min-width: 768px)': { + display: 'none', + }, + }, +}) + +export const statsBlock = style({ + marginTop: '28px', + '@media': { + 'screen and (max-width: 767px)': { + marginTop: 0, + }, + 'screen and (min-width: 768px)': { + marginTop: '48px', + }, + }, +}) + +export const daoCard = style({ + display: 'flex', + flexDirection: 'column', + gap: '18px', + minHeight: '320px', + borderRadius: '12px', + border: standardBorder, + background: theme.colors.background1, + padding: '18px', + boxShadow: 'none', + minWidth: 0, + color: 'inherit', + textDecoration: 'none', + transition: 'border-color 0.15s ease, background-color 0.15s ease', + selectors: { + '&:hover': { + borderColor: softBlueBorder, + backgroundColor: softBlueBackground, + cursor: 'pointer', + }, + '&:focus-visible': focusRing, + }, + '@media': { + 'screen and (min-width: 768px)': { + minHeight: '360px', + }, + }, +}) + +export const daoTop = style({ + display: 'flex', + justifyContent: 'space-between', + gap: '12px', + alignItems: 'flex-start', +}) + +export const daoChainBadge = style({ + width: '34px', + height: '34px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, +}) + +export const daoChainBadgeImage = style({ + width: '20px', + height: '20px', + objectFit: 'contain', + display: 'block', +}) + +export const daoIdentity = style({ + display: 'flex', + gap: '12px', + alignItems: 'flex-start', + minWidth: 0, +}) + +export const daoAvatar = style({ + width: '52px', + height: '52px', + borderRadius: '999px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontFamily: 'ptRoot, sans-serif', + fontSize: '18px', + fontWeight: 700, + flexShrink: 0, + overflow: 'hidden', +}) + +export const daoAvatarSurfaceA = style({ + background: theme.colors.background2, + color: theme.colors.text1, +}) +export const daoAvatarSurfaceB = style({ + background: `color-mix(in srgb, ${theme.colors.focusRing} 22%, ${theme.colors.background1})`, + color: theme.colors.text1, +}) +export const daoAvatarSurfaceC = style({ + background: theme.colors.positive, + color: theme.colors.text1, +}) +export const daoAvatarSurfaceD = style({ + background: theme.colors.warning, + color: theme.colors.text1, +}) + +export const daoAvatarImage = style({ + width: '100%', + height: '100%', + borderRadius: 'inherit', + objectFit: 'cover', + display: 'block', +}) + +export const daoName = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '20px', + lineHeight: 1.05, + color: theme.colors.text1, + overflowWrap: 'anywhere', +}) + +export const daoDescription = style({ + marginTop: '6px', + fontSize: '14px', + lineHeight: 1.6, + color: theme.colors.text3, +}) + +globalStyle(`${daoDescription} p`, { + margin: 0, +}) + +globalStyle(`${daoDescription} strong`, { + fontWeight: 700, + color: theme.colors.text2, +}) + +export const badge = style({ + display: 'inline-flex', + width: 'fit-content', + borderRadius: '999px', + border: standardBorderThin, + padding: '6px 10px', + fontSize: '12px', + fontWeight: 700, +}) + +export const daoSignal = style({ + display: 'grid', + gap: '6px', + marginTop: 'auto', + padding: '14px 16px', + borderRadius: '10px', + background: theme.colors.background2, +}) + +export const daoSignalLabel = style({ + fontSize: '12px', + textTransform: 'uppercase', + letterSpacing: '0.06em', + color: theme.colors.text4, + fontWeight: 700, +}) + +export const daoSignalValue = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '18px', + lineHeight: 1.2, + whiteSpace: 'normal', + overflowWrap: 'anywhere', + color: theme.colors.text1, +}) + +export const cardLink = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '10px', + color: theme.colors.text1, + fontSize: '14px', + fontWeight: 700, + textDecoration: 'none', + selectors: { + '&:hover': { + opacity: 0.7, + }, + '&:focus-visible': focusRing, + }, +}) + +export const scrollRow = style({ + display: 'grid', + gap: '16px', + gridAutoFlow: 'column', + gridAutoColumns: 'minmax(84vw, 1fr)', + overflowX: 'auto', + paddingBottom: '8px', + scrollSnapType: 'x mandatory', + selectors: { + '&::-webkit-scrollbar': { + height: '10px', + }, + '&::-webkit-scrollbar-thumb': { + background: theme.colors.border, + borderRadius: '999px', + }, + }, + '@media': { + 'screen and (min-width: 640px)': { + gridAutoColumns: 'minmax(280px, 1fr)', + }, + 'screen and (min-width: 1080px)': { + gridAutoColumns: 'minmax(280px, 320px)', + }, + }, +}) + +export const coiningCard = style({ + display: 'flex', + flexDirection: 'column', + gap: '16px', + minHeight: '320px', + scrollSnapAlign: 'start', + borderRadius: '12px', + border: standardBorder, + background: theme.colors.background1, + padding: '18px', + boxShadow: 'none', + minWidth: 0, + color: 'inherit', + textDecoration: 'none', + transition: 'border-color 0.15s ease, background-color 0.15s ease', + selectors: { + '&:hover': { + borderColor: softBlueBorder, + backgroundColor: softBlueBackground, + cursor: 'pointer', + }, + '&:focus-visible': focusRing, + }, + '@media': { + 'screen and (min-width: 768px)': { + minHeight: '360px', + }, + }, +}) + +export const coiningPreview = style({ + position: 'relative', + minHeight: '180px', + borderRadius: '10px', + overflow: 'hidden', + padding: '16px', + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + border: standardBorderThin, + '@media': { + 'screen and (min-width: 768px)': { + minHeight: '220px', + }, + }, +}) + +export const coiningPreviewSurfaceA = style({ background: theme.colors.accent }) +export const coiningPreviewSurfaceB = style({ background: theme.colors.warning }) +export const coiningPreviewSurfaceC = style({ background: theme.colors.positive }) +export const coiningPreviewSurfaceD = style({ background: theme.colors.background2 }) + +export const coiningPreviewTop = style({ + display: 'flex', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: '12px', +}) + +export const coiningPreviewMark = style({ + alignSelf: 'flex-start', + display: 'inline-flex', + borderRadius: '999px', + background: theme.colors.background1, + border: standardBorderThin, + padding: '6px 10px', + fontSize: '12px', + fontWeight: 700, + color: theme.colors.text1, +}) + +export const coiningNetworkBadge = style({ + width: '34px', + height: '34px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, +}) + +export const coiningPreviewTitle = style({ + maxWidth: '12ch', + fontFamily: 'ptRoot, sans-serif', + fontSize: '26px', + lineHeight: 0.95, + color: theme.colors.text1, + overflowWrap: 'anywhere', + '@media': { + 'screen and (min-width: 768px)': { + fontSize: '32px', + }, + }, +}) + +export const coiningMeta = style({ + display: 'grid', + gap: '6px', + flex: 1, +}) + +export const coiningTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '22px', + lineHeight: 1.05, + color: theme.colors.text1, +}) + +export const mutedText = style({ + fontSize: '14px', + lineHeight: 1.6, + color: theme.colors.text3, +}) + +export const amountPill = style({ + display: 'inline-flex', + width: 'fit-content', + borderRadius: '999px', + padding: '7px 12px', + background: theme.colors.background2, + color: theme.colors.text1, + border: standardBorderThin, + fontSize: '13px', + fontWeight: 700, +}) + +export const droposalList = style({ + display: 'grid', + gap: '14px', +}) + +export const droposalCard = style({ + display: 'grid', + gap: '14px', + borderRadius: '12px', + border: standardBorder, + background: theme.colors.background1, + padding: '18px', + boxShadow: 'none', + color: 'inherit', + textDecoration: 'none', + transition: 'border-color 0.15s ease, background-color 0.15s ease', + selectors: { + '&:hover': { + borderColor: softBlueBorder, + backgroundColor: softBlueBackground, + cursor: 'pointer', + }, + '&:focus-visible': focusRing, + }, + '@media': { + 'screen and (min-width: 880px)': { + gridTemplateColumns: 'minmax(0, 1fr) auto', + alignItems: 'center', + }, + }, +}) + +export const droposalMeta = style({ + display: 'flex', + flexWrap: 'wrap', + gap: '10px', + alignItems: 'center', +}) + +export const statusBadge = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: '88px', + borderRadius: '999px', + border: standardBorderThin, + padding: '7px 12px', + fontSize: '12px', + fontWeight: 700, + background: 'transparent', +}) + +export const daoBadge = style({ + background: '#F7F3E8', + color: '#4F4738', + borderColor: theme.colors.text1, +}) + +export const droposalTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '20px', + lineHeight: 1.05, + color: theme.colors.text1, + overflowWrap: 'anywhere', + '@media': { + 'screen and (min-width: 768px)': { + fontSize: '24px', + }, + }, +}) + +export const droposalSummary = style({ + fontSize: '15px', + lineHeight: 1.65, + color: theme.colors.text3, + maxWidth: '72ch', +}) + +export const droposalAside = style({ + display: 'grid', + gap: '12px', + justifyItems: 'start', + '@media': { + 'screen and (min-width: 880px)': { + justifyItems: 'end', + textAlign: 'right', + }, + }, +}) + +export const stepsGrid = style({ + display: 'grid', + gap: '16px', + gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', + '@media': { + 'screen and (min-width: 860px)': { + gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', + }, + }, +}) + +export const stepCard = style({ + minHeight: '170px', + borderRadius: '12px', + border: standardBorder, + background: theme.colors.background1, + padding: '22px', + boxShadow: 'none', +}) + +export const stepHeader = style({ + display: 'flex', + alignItems: 'flex-start', + gap: '16px', +}) + +export const stepMarker = style({ + display: 'inline-flex', + minWidth: '48px', + height: '48px', + padding: '0 16px', + borderRadius: '10px', + alignItems: 'center', + justifyContent: 'center', + background: theme.colors.focusRing, + border: 'none', + color: theme.colors.background1, + fontFamily: 'ptRoot, sans-serif', + fontSize: '18px', + flexShrink: 0, +}) + +export const stepTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '28px', + lineHeight: 1.02, + color: theme.colors.text1, +}) + +export const stepBody = style({ + marginTop: '14px', + fontSize: '15px', + lineHeight: 1.65, + color: theme.colors.text3, +}) + +export const valueGrid = style({ + display: 'grid', + gap: '16px', + gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', + '@media': { + 'screen and (min-width: 700px)': { + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + }, + 'screen and (min-width: 1080px)': { + gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', + }, + }, +}) + +export const featureStrip = style({ + position: 'relative', + display: 'grid', + width: '100%', + maxWidth: '100%', + alignSelf: 'stretch', + gap: '18px', + gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', + '@media': { + 'screen and (min-width: 700px)': { + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + }, + 'screen and (min-width: 1080px)': { + gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', + gap: '24px', + }, + }, +}) + +export const featureItem = style({ + display: 'grid', + gridTemplateColumns: '42px minmax(0, 1fr)', + gap: '12px', + alignItems: 'center', + minWidth: 0, + '@media': { + 'screen and (min-width: 700px)': { + gridTemplateColumns: '42px minmax(0, 1fr)', + justifyItems: 'stretch', + alignItems: 'center', + gap: '12px', + }, + }, +}) + +export const featureIconBadge = style({ + position: 'relative', + zIndex: 1, + width: '42px', + height: '42px', + borderRadius: '14px', + background: theme.colors.background2, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + color: theme.colors.text1, +}) + +globalStyle(`${featureIconBadge} svg`, { + width: '20px', + height: '20px', + display: 'block', +}) + +export const featureLabel = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '17px', + lineHeight: 1.2, + fontWeight: 700, + color: theme.colors.text1, + overflowWrap: 'anywhere', +}) + +export const useCaseGrid = style({ + display: 'grid', + width: '100%', + maxWidth: '100%', + alignSelf: 'stretch', + gap: '16px', + gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', + '@media': { + 'screen and (min-width: 640px)': { + gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', + }, + 'screen and (min-width: 900px)': { + gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', + }, + 'screen and (min-width: 1180px)': { + gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', + }, + }, +}) + +export const valueCard = style({ + minHeight: '150px', + borderRadius: '12px', + border: standardBorder, + background: theme.colors.background1, + padding: '20px', + boxShadow: 'none', +}) + +export const compactValueCard = style({ + minHeight: '96px', + borderRadius: '12px', + border: standardBorder, + background: theme.colors.background1, + padding: '16px', + boxShadow: 'none', + display: 'flex', + flexDirection: 'column', + gap: '10px', + minWidth: 0, + '@media': { + 'screen and (min-width: 768px)': { + minHeight: '108px', + padding: '18px', + }, + }, +}) + +export const valueTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '24px', + lineHeight: 1.05, + color: theme.colors.text1, +}) + +export const compactValueTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '18px', + lineHeight: 1.2, + color: theme.colors.text1, + display: '-webkit-box', + overflow: 'hidden', + WebkitLineClamp: 3, + WebkitBoxOrient: 'vertical', +}) + +export const compactValueEmoji = style({ + fontSize: '28px', + lineHeight: 1, +}) + +export const compactValueImage = style({ + width: '32px', + height: '32px', + objectFit: 'contain', + display: 'block', +}) + +export const activityList = style({ + display: 'grid', + gap: '14px', +}) + +export const activityCard = style({ + display: 'grid', + gap: '10px', + borderRadius: '12px', + border: standardBorder, + background: theme.colors.background1, + padding: '18px', + '@media': { + 'screen and (min-width: 820px)': { + gridTemplateColumns: '140px minmax(0, 1fr)', + alignItems: 'start', + gap: '18px', + }, + }, +}) + +export const activityMeta = style({ + display: 'inline-flex', + width: 'fit-content', + borderRadius: '999px', + padding: '6px 10px', + fontSize: '12px', + fontWeight: 700, + background: theme.colors.background2, + color: theme.colors.text3, + border: standardBorderThin, +}) + +export const activityTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '22px', + lineHeight: 1.08, + color: theme.colors.text1, +}) + +export const finalCta = style({ + position: 'relative', + overflow: 'hidden', + borderRadius: '16px', + border: standardBorder, + background: theme.colors.background2, + color: theme.colors.text1, + padding: '30px 22px', + boxShadow: 'none', + '@media': { + 'screen and (min-width: 768px)': { + padding: '40px 34px', + }, + }, +}) + +export const finalCtaGlow = style({ + position: 'absolute', + inset: 0, + pointerEvents: 'none', + background: 'transparent', +}) + +export const finalCtaContent = style({ + position: 'relative', + zIndex: 1, + display: 'grid', + gap: '24px', + alignItems: 'center', + minWidth: 0, + '@media': { + 'screen and (min-width: 980px)': { + gridTemplateColumns: 'minmax(0, 1fr) auto', + gap: '32px', + }, + }, +}) + +export const finalCtaTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '32px', + lineHeight: 1, + whiteSpace: 'normal', + overflowWrap: 'anywhere', + '@media': { + 'screen and (min-width: 768px)': { + fontSize: '52px', + whiteSpace: 'nowrap', + overflowWrap: 'normal', + }, + }, +}) + +export const finalChecklist = style({ + marginTop: '12px', + marginBottom: 0, + padding: 0, + listStyle: 'none', + display: 'grid', + gap: '10px', +}) + +export const finalChecklistItem = style({ + display: 'flex', + alignItems: 'flex-start', + gap: '10px', + fontSize: '15px', + lineHeight: 1.55, + color: theme.colors.text3, + '@media': { + 'screen and (min-width: 768px)': { + alignItems: 'center', + fontSize: '17px', + lineHeight: 1.6, + }, + }, +}) + +export const finalChecklistMarker = style({ + minWidth: '24px', + color: theme.colors.text1, + fontWeight: 700, +}) + +export const finalActions = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', + alignItems: 'stretch', + '@media': { + 'screen and (min-width: 768px)': { + flexDirection: 'row', + alignItems: 'center', + }, + 'screen and (min-width: 980px)': { + justifyContent: 'flex-end', + }, + }, +}) + +export const aboutCtaButton = style({ + borderRadius: '999px', + transition: 'background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease', + selectors: { + '&:not([disabled]):hover': { + borderColor: theme.colors.focusRing, + backgroundColor: `color-mix(in srgb, ${theme.colors.focusRing} 14%, ${theme.colors.background1})`, + }, + '&:focus-visible': focusRing, + }, +}) + +globalStyle(`${aboutCtaButton}.zord-button-primary:not([disabled]):hover`, { + borderColor: theme.colors.focusRing, + backgroundColor: `color-mix(in srgb, ${theme.colors.focusRing} 82%, ${theme.colors.text1})`, + color: theme.colors.background1, +}) + +globalStyle(`${aboutCtaButton}.zord-button-outline:not([disabled]):hover`, { + borderColor: theme.colors.focusRing, + backgroundColor: `color-mix(in srgb, ${theme.colors.focusRing} 14%, ${theme.colors.background1})`, + color: theme.colors.text1, +}) + +globalStyle(`${aboutCtaButton}.zord-button-ghost:not([disabled]):hover`, { + borderColor: theme.colors.focusRing, + backgroundColor: `color-mix(in srgb, ${theme.colors.focusRing} 16%, ${theme.colors.background1})`, + color: theme.colors.text1, +}) + +export const subLink = style({ + color: theme.colors.text3, + fontSize: '14px', + fontWeight: 700, + textDecoration: 'none', + width: 'fit-content', + selectors: { + '&:hover': { + color: theme.colors.text1, + }, + '&:focus-visible': focusRing, + }, +}) + +export const heroSubLink = style({ + color: theme.colors.text3, + fontSize: '14px', + fontWeight: 700, + textDecoration: 'none', + selectors: { + '&:hover': { + color: theme.colors.text1, + }, + '&:focus-visible': focusRing, + }, +}) + +export const helperText = style({ + fontSize: '14px', + lineHeight: 1.55, + color: theme.colors.text3, +}) + +export const governanceCopy = style({ + maxWidth: '720px', + fontSize: '17px', + lineHeight: 1.6, + color: theme.colors.text3, +}) + +export const governanceLink = style({ + color: theme.colors.focusRing, + textDecoration: 'underline', +}) + +globalStyle( + `html[data-theme-mode="dark"] ${page} :is(${sectionTitle}, ${heroTitle}, ${montageValue}, ${heroFooterValue}, ${statValue}, ${tabButton}, ${activeTabButton}, ${mobileStatsHeading}, ${daoName}, ${daoSignalValue}, ${coiningTitle}, ${amountPill}, ${droposalTitle}, ${stepTitle}, ${featureItem})`, + { + color: theme.colors.text1, + } +) + +globalStyle( + `html[data-theme-mode="dark"] ${page} :is(${sectionCopy}, ${introCopyNoWrap}, ${heroText}, ${heroHighlight}, ${montageBody}, ${statLabel}, ${statDetail}, ${sectionInlineCopy}, ${daoDescription}, ${daoSignalLabel}, ${mutedText}, ${droposalSummary}, ${stepBody}, ${subLink}, ${heroSubLink}, ${helperText}, ${governanceCopy})`, + { + color: theme.colors.text3, + } +) + +globalStyle(`html[data-theme-mode="dark"] ${page} :is(${heroHighlightDot})`, { + background: theme.colors.text1, +}) + +globalStyle( + `html[data-theme-mode="dark"] ${page} ${aboutCtaButton}:not([disabled]):hover`, + { + borderColor: theme.colors.focusRing, + backgroundColor: `color-mix(in srgb, ${theme.colors.focusRing} 30%, ${theme.colors.background1})`, + color: theme.colors.text1, + } +) + +globalStyle( + `html[data-theme-mode="dark"] ${page} ${aboutCtaButton}.zord-button-primary:not([disabled]):hover`, + { + borderColor: theme.colors.focusRing, + backgroundColor: `color-mix(in srgb, ${theme.colors.focusRing} 70%, ${theme.colors.text1})`, + color: theme.colors.background1, + } +) + +globalStyle(`html[data-theme-mode="dark"] ${page} :is(${daoMiniCard}, ${stepCard})`, { + background: theme.colors.background2, +}) + +globalStyle(`html[data-theme-mode="dark"] ${page} :is(${coiningPreviewTitle})`, { + color: theme.colors.background1, +}) + +globalStyle(`html[data-theme-mode="dark"] ${logoMarqueeTrack}::before`, { + background: + 'linear-gradient(90deg, rgba(31, 32, 36, 1) 0%, rgba(31, 32, 36, 0) 10%, rgba(31, 32, 36, 0) 90%, rgba(31, 32, 36, 1) 100%)', +}) diff --git a/apps/web/src/modules/about/AboutPage.tsx b/apps/web/src/modules/about/AboutPage.tsx new file mode 100644 index 000000000..a22209557 --- /dev/null +++ b/apps/web/src/modules/about/AboutPage.tsx @@ -0,0 +1,149 @@ +import { useChainStore } from '@buildeross/stores' +import { Box } from '@buildeross/zord' +import React from 'react' + +import { + centeredImage, + centeredImageWrap, + container, + governanceCopy, + governanceLink, + page, + section, +} from './AboutPage.css' +import { AboutFinalCta } from './components/AboutFinalCta' +import { AboutHero } from './components/AboutHero' +import { CoiningHighlightsSection } from './components/CoiningHighlightsSection' +import { DroposalHighlightsSection } from './components/DroposalHighlightsSection' +import { FeaturedDaoSection } from './components/FeaturedDaoSection' +import { ProposalHighlightsSection } from './components/ProposalHighlightsSection' +import { SectionIntro } from './components/SectionIntro' +import { WhatIsBuilderSection } from './components/WhatIsBuilderSection' +import { WhatYouCanBuildSection } from './components/WhatYouCanBuildSection' +import { WhyBuilderSection } from './components/WhyBuilderSection' +import { heroHighlights } from './data' +import { useAboutDaoTabs } from './useAboutDaoTabs' +import { useAboutShowcase } from './useAboutShowcase' +import { useAboutSnapshotStats } from './useAboutSnapshotStats' + +export const AboutPageView: React.FC = () => { + const chain = useChainStore((x) => x.chain) + const { data: tabsData, isLoading } = useAboutDaoTabs(chain.slug) + const { data: snapshotData } = useAboutSnapshotStats() + const { data: showcaseData } = useAboutShowcase() + const heroLogos = React.useMemo(() => { + if (!tabsData) return [] + + const seen = new Set() + + return Object.values(tabsData) + .flat() + .filter((dao) => dao.recentAuctionImage) + .filter((dao) => { + if (!dao.recentAuctionImage || seen.has(dao.recentAuctionImage)) return false + seen.add(dao.recentAuctionImage) + return true + }) + .slice(0, 12) + .map((dao) => ({ + id: dao.id, + name: dao.name, + imageUrl: dao.recentAuctionImage as string, + })) + }, [tabsData]) + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + This public good tooling and the Nouns Builder Protocol are maintained and + governed by{' '} + + Builder DAO + + . Learn more about the DAO's vision and mission{' '} + + here + + . + + + + + + + + + + + + + + + + ) +} diff --git a/apps/web/src/modules/about/components/AboutFinalCta.tsx b/apps/web/src/modules/about/components/AboutFinalCta.tsx new file mode 100644 index 000000000..f1d8e28f1 --- /dev/null +++ b/apps/web/src/modules/about/components/AboutFinalCta.tsx @@ -0,0 +1,80 @@ +import { Box, Button, Text } from '@buildeross/zord' +import Link from 'next/link' +import React from 'react' + +import { + aboutCtaButton, + finalActions, + finalChecklist, + finalChecklistItem, + finalChecklistMarker, + finalCta, + finalCtaContent, + finalCtaGlow, + finalCtaTitle, +} from '../AboutPage.css' + +export const AboutFinalCta: React.FC = () => { + return ( + + + + + Start your DAO today + + + + Upload your art + + + + Set your parameters + + + + Launch your first auction + + + + + + + + + + + + ) +} diff --git a/apps/web/src/modules/about/components/AboutHero.tsx b/apps/web/src/modules/about/components/AboutHero.tsx new file mode 100644 index 000000000..df786a2b2 --- /dev/null +++ b/apps/web/src/modules/about/components/AboutHero.tsx @@ -0,0 +1,140 @@ +import { Box, Button, Text } from '@buildeross/zord' +import Link from 'next/link' +import React from 'react' + +import { + aboutCtaButton, + aboutDarkOnly, + aboutLightOnly, + hero, + heroActions, + heroCopy, + heroHighlight, + heroHighlightDot, + heroHighlightList, + heroPanel, + heroPanelGlow, + heroText, + heroTitle, + heroVennImage, + heroVennWrap, + logoMarquee, + logoMarqueeImage, + logoMarqueeInner, + logoMarqueeItem, + logoMarqueeTrack, + montageBody, + montageCard, + montageFooter, + montageGrid, + montagePrimary, + montageSide, + montageValue, +} from '../AboutPage.css' + +type AboutHeroProps = { + heroHighlights: string[] + heroLogos: Array<{ + id: string + name: string + imageUrl: string + }> +} + +export const AboutHero: React.FC = ({ heroHighlights, heroLogos }) => { + const marqueeLogos = heroLogos.length > 0 ? [...heroLogos, ...heroLogos] : [] + + return ( + + + + The easiest way to build communities onchain + + + Nouns Builder lets anyone launch a DAO where membership, funding, content and + governance all happen in one fully transparent, onchain system. + + + + {heroHighlights.map((item) => ( + + + {item} + + ))} + + + + + + + + + + + + + + {`Launch.\nFund.\nCollaborate.`} + + The onchain operating system for decentralized communities. + + + + + + + + + + + + + + + {marqueeLogos.map((logo, index) => ( + + + + ))} + + + + + + + + ) +} diff --git a/apps/web/src/modules/about/components/CoiningCard.tsx b/apps/web/src/modules/about/components/CoiningCard.tsx new file mode 100644 index 000000000..f468c13aa --- /dev/null +++ b/apps/web/src/modules/about/components/CoiningCard.tsx @@ -0,0 +1,60 @@ +import { Box, Text } from '@buildeross/zord' +import Link from 'next/link' +import React from 'react' + +import { + coiningCard, + coiningMeta, + coiningNetworkBadge, + coiningPreview, + coiningPreviewMark, + coiningPreviewTitle, + coiningPreviewTop, + coiningTitle, + daoChainBadgeImage, + mutedText, +} from '../AboutPage.css' +import { CoiningHighlight } from '../types' +import { getChainLogoSrc } from '../utils' + +type CoiningCardProps = { + item: CoiningHighlight +} + +export const CoiningCard: React.FC = ({ item }) => { + const chainLogoSrc = getChainLogoSrc(item.chainLabel) + + return ( + + + + {item.amount} + {chainLogoSrc ? ( + + + + ) : ( + {item.chainLabel} + )} + + {item.previewLabel} + + + + {item.title} + + By {item.creator} for {item.dao} + + + + ) +} diff --git a/apps/web/src/modules/about/components/CoiningHighlightsSection.tsx b/apps/web/src/modules/about/components/CoiningHighlightsSection.tsx new file mode 100644 index 000000000..c3ecec7c3 --- /dev/null +++ b/apps/web/src/modules/about/components/CoiningHighlightsSection.tsx @@ -0,0 +1,34 @@ +import { Box } from '@buildeross/zord' +import React from 'react' + +import { scrollRow } from '../AboutPage.css' +import { coiningHighlights } from '../data' +import { CoiningHighlight } from '../types' +import { CoiningCard } from './CoiningCard' +import { SectionIntro } from './SectionIntro' + +type CoiningHighlightsSectionProps = { + items?: CoiningHighlight[] +} + +export const CoiningHighlightsSection: React.FC = ({ + items, +}) => { + const highlights = items?.length ? items : coiningHighlights + + return ( + + + + + {highlights.map((item) => ( + + ))} + + + ) +} diff --git a/apps/web/src/modules/about/components/DaoCard.tsx b/apps/web/src/modules/about/components/DaoCard.tsx new file mode 100644 index 000000000..217b0ab3d --- /dev/null +++ b/apps/web/src/modules/about/components/DaoCard.tsx @@ -0,0 +1,80 @@ +import { FallbackImage } from '@buildeross/ui' +import { Box, Flex, Text } from '@buildeross/zord' +import Link from 'next/link' +import React from 'react' + +import { + daoAvatar, + daoAvatarImage, + daoAvatarSurfaceA, + daoAvatarSurfaceB, + daoAvatarSurfaceC, + daoAvatarSurfaceD, + daoCard, + daoChainBadge, + daoChainBadgeImage, + daoDescription, + daoIdentity, + daoName, + daoSignal, + daoSignalLabel, + daoSignalValue, + daoTop, +} from '../AboutPage.css' +import { AboutDao } from '../types' +import { getChainLogoSrc, toPlainText } from '../utils' + +type DaoCardProps = { + dao: AboutDao +} + +export const DaoCard: React.FC = ({ dao }) => { + const plainDescription = toPlainText(dao.description) + const chainLogoSrc = getChainLogoSrc(dao.chainName) || dao.chainIcon + + const avatarSurfaceClass = [ + daoAvatarSurfaceA, + daoAvatarSurfaceB, + daoAvatarSurfaceC, + daoAvatarSurfaceD, + ][Number(dao.id.replace(/\D/g, '')) % 4 || 0] + + return ( + + + + + {dao.imageUrl ? ( + + ) : ( + dao.initials + )} + + + {dao.name} + {plainDescription} + + + {chainLogoSrc ? ( + + + + ) : null} + + + + {dao.signalLabel} + {dao.signalValue} + + + ) +} diff --git a/apps/web/src/modules/about/components/DroposalHighlightsSection.tsx b/apps/web/src/modules/about/components/DroposalHighlightsSection.tsx new file mode 100644 index 000000000..e9bbdd252 --- /dev/null +++ b/apps/web/src/modules/about/components/DroposalHighlightsSection.tsx @@ -0,0 +1,139 @@ +import { getProposalStateColorStyle } from '@buildeross/proposal-ui' +import { ProposalState } from '@buildeross/sdk/contract' +import { Box, Text } from '@buildeross/zord' +import Link from 'next/link' +import React from 'react' + +import { + badge, + daoBadge, + daoChainBadge, + daoChainBadgeImage, + droposalAside, + droposalCard, + droposalList, + droposalMeta, + droposalSummary, + droposalTitle, + mutedText, + statusBadge, +} from '../AboutPage.css' +import { dropHighlights } from '../data' +import { DroposalHighlight } from '../types' +import { getChainLogoSrc } from '../utils' +import { SectionIntro } from './SectionIntro' + +const statusStyleByType: Record< + DroposalHighlight['status'], + { borderColor: string; color: string; backgroundColor: string } +> = { + Active: { + ...getProposalStateColorStyle(ProposalState.Active), + backgroundColor: `color-mix(in srgb, ${getProposalStateColorStyle(ProposalState.Active).borderColor} 12%, transparent)`, + }, + Succeeded: { + ...getProposalStateColorStyle(ProposalState.Succeeded), + backgroundColor: `color-mix(in srgb, ${getProposalStateColorStyle(ProposalState.Succeeded).borderColor} 12%, transparent)`, + }, + Queued: { + ...getProposalStateColorStyle(ProposalState.Queued), + backgroundColor: `color-mix(in srgb, ${getProposalStateColorStyle(ProposalState.Queued).borderColor} 12%, transparent)`, + }, + Defeated: { + ...getProposalStateColorStyle(ProposalState.Defeated), + backgroundColor: `color-mix(in srgb, ${getProposalStateColorStyle(ProposalState.Defeated).borderColor} 12%, transparent)`, + }, + Executed: { + ...getProposalStateColorStyle(ProposalState.Executed), + backgroundColor: `color-mix(in srgb, ${getProposalStateColorStyle(ProposalState.Executed).borderColor} 12%, transparent)`, + }, + Trending: { + ...getProposalStateColorStyle(ProposalState.Succeeded), + backgroundColor: `color-mix(in srgb, ${getProposalStateColorStyle(ProposalState.Succeeded).borderColor} 12%, transparent)`, + }, + Live: { + ...getProposalStateColorStyle(ProposalState.Succeeded), + backgroundColor: `color-mix(in srgb, ${getProposalStateColorStyle(ProposalState.Succeeded).borderColor} 12%, transparent)`, + }, + Recent: { + ...getProposalStateColorStyle(ProposalState.Active), + backgroundColor: `color-mix(in srgb, ${getProposalStateColorStyle(ProposalState.Active).borderColor} 12%, transparent)`, + }, +} + +type DroposalHighlightsSectionProps = { + items?: DroposalHighlight[] + eyebrowText?: string + title?: string + copy?: string + linkLabel?: string + showStatusBadge?: boolean +} + +export const DroposalHighlightsSection: React.FC = ({ + items, + eyebrowText = 'Drops', + title = 'Drops turn releases into onchain distribution', + copy = 'Launch collectible drops that turn media, editions, and releases into distribution, ownership, and treasury growth for decentralized communities.', + linkLabel = 'View drop', + showStatusBadge = true, +}) => { + const highlights = items?.length ? items : dropHighlights + + return ( + + + + + {highlights.map((proposal) => { + const chainLogoSrc = getChainLogoSrc(proposal.category) + + return ( + + + + {proposal.dao} + {showStatusBadge ? ( + + {proposal.status} + + ) : null} + {chainLogoSrc ? ( + + + + ) : ( + {proposal.category} + )} + + + {proposal.title} + + + {proposal.summary} + + + + + {proposal.amount} + + + ) + })} + + + ) +} diff --git a/apps/web/src/modules/about/components/EcosystemActivitySection.tsx b/apps/web/src/modules/about/components/EcosystemActivitySection.tsx new file mode 100644 index 000000000..e1d9a7ac1 --- /dev/null +++ b/apps/web/src/modules/about/components/EcosystemActivitySection.tsx @@ -0,0 +1,40 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + activityCard, + activityList, + activityMeta, + activityTitle, + mutedText, +} from '../AboutPage.css' +import { ecosystemActivity } from '../data' +import { SectionIntro } from './SectionIntro' + +export const EcosystemActivitySection: React.FC = () => { + return ( + + + + + {ecosystemActivity.map((item) => ( + + {item.meta} + + + {item.title} + + + {item.detail} + + + + ))} + + + ) +} diff --git a/apps/web/src/modules/about/components/EcosystemStatGrid.tsx b/apps/web/src/modules/about/components/EcosystemStatGrid.tsx new file mode 100644 index 000000000..fe2acab7d --- /dev/null +++ b/apps/web/src/modules/about/components/EcosystemStatGrid.tsx @@ -0,0 +1,56 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + statAccent, + statAccentAuction, + statAccentDao, + statAccentMembers, + statAccentProposal, + statAccentTokens, + statAccentTreasury, + statCard, + statDetail, + statGrid, + statLabel, + statValue, +} from '../AboutPage.css' +import { AboutStat } from '../types' + +type EcosystemStatGridProps = { + stats: AboutStat[] +} + +export const EcosystemStatGrid: React.FC = ({ stats }) => { + const getAccentClass = (id: string) => { + switch (id) { + case 'daos': + return statAccentDao + case 'treasury': + return statAccentTreasury + case 'auctions': + return statAccentAuction + case 'proposals': + return statAccentProposal + case 'members': + return statAccentMembers + case 'tokens': + return statAccentTokens + default: + return statAccentDao + } + } + + return ( + + {stats.map((stat) => ( + + {stat.icon} + {stat.label} + {stat.value} + {stat.detail} + + ))} + + ) +} diff --git a/apps/web/src/modules/about/components/FeaturedDaoSection.tsx b/apps/web/src/modules/about/components/FeaturedDaoSection.tsx new file mode 100644 index 000000000..c637be2c7 --- /dev/null +++ b/apps/web/src/modules/about/components/FeaturedDaoSection.tsx @@ -0,0 +1,88 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + activeTabButton, + daoGrid, + mobileStatsHeading, + sectionInlineCopy, + sectionInlineRow, + sectionTitle, + sectionTitleOnly, + statsBlock, + tabButton, + tabs, +} from '../AboutPage.css' +import { AboutDaoTabKey, AboutDaoTabsResponse, AboutStat } from '../types' +import { DaoCard } from './DaoCard' +import { EcosystemStatGrid } from './EcosystemStatGrid' + +type FeaturedDaoSectionProps = { + tabsData?: AboutDaoTabsResponse + stats?: AboutStat[] + isLoading: boolean +} + +const tabOrder: { id: AboutDaoTabKey; label: string }[] = [ + { id: 'featured', label: 'Featured' }, + { id: 'trending', label: 'Trending' }, + { id: 'new', label: 'New' }, + { id: 'active', label: 'Active' }, +] + +export const FeaturedDaoSection: React.FC = ({ + tabsData, + stats, + isLoading: _isLoading, +}) => { + const [tab, setTab] = React.useState('featured') + + const displayedDaos = React.useMemo(() => { + return tabsData?.[tab] ?? [] + }, [tab, tabsData]) + + return ( + + + A thriving ecosystem + + + + + Nouns Builder is already powering a growing network of decentralized + communities. + + + + {tabOrder.map((item) => { + const isActive = item.id === tab + return ( + + ) + })} + + + + + {displayedDaos.map((dao) => ( + + ))} + + + + Protocol Stats + + + + + + + ) +} diff --git a/apps/web/src/modules/about/components/HowItWorksSection.tsx b/apps/web/src/modules/about/components/HowItWorksSection.tsx new file mode 100644 index 000000000..4d3d569ae --- /dev/null +++ b/apps/web/src/modules/about/components/HowItWorksSection.tsx @@ -0,0 +1,35 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + stepBody, + stepCard, + stepHeader, + stepMarker, + stepsGrid, + stepTitle, +} from '../AboutPage.css' +import { builderSteps } from '../data' +import { SectionIntro } from './SectionIntro' + +export const HowItWorksSection: React.FC = () => { + return ( + + + + + {builderSteps.map((step) => ( + + + {step.marker} + + {step.title} + + + {step.body} + + ))} + + + ) +} diff --git a/apps/web/src/modules/about/components/ProposalHighlightsSection.tsx b/apps/web/src/modules/about/components/ProposalHighlightsSection.tsx new file mode 100644 index 000000000..b494f4a42 --- /dev/null +++ b/apps/web/src/modules/about/components/ProposalHighlightsSection.tsx @@ -0,0 +1,25 @@ +import React from 'react' + +import { proposalHighlights } from '../data' +import { DroposalHighlight } from '../types' +import { DroposalHighlightsSection } from './DroposalHighlightsSection' + +type ProposalHighlightsSectionProps = { + items?: DroposalHighlight[] +} + +export const ProposalHighlightsSection: React.FC = ({ + items, +}) => { + const highlights = items?.length ? items : proposalHighlights + + return ( + + ) +} diff --git a/apps/web/src/modules/about/components/SectionIntro.tsx b/apps/web/src/modules/about/components/SectionIntro.tsx new file mode 100644 index 000000000..75aa29d0c --- /dev/null +++ b/apps/web/src/modules/about/components/SectionIntro.tsx @@ -0,0 +1,28 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { eyebrow, sectionCopy, sectionHeader, sectionTitle } from '../AboutPage.css' + +type SectionIntroProps = { + eyebrowText: string + title: string + copy?: string + copyClassName?: string +} + +export const SectionIntro: React.FC = ({ + eyebrowText, + title, + copy, + copyClassName, +}) => { + return ( + + {eyebrowText} + + {title} + + {copy ? {copy} : null} + + ) +} diff --git a/apps/web/src/modules/about/components/WhatIsBuilderSection.tsx b/apps/web/src/modules/about/components/WhatIsBuilderSection.tsx new file mode 100644 index 000000000..298b88146 --- /dev/null +++ b/apps/web/src/modules/about/components/WhatIsBuilderSection.tsx @@ -0,0 +1,43 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + mutedText, + stepHeader, + stepMarker, + valueCard, + valueGrid, + valueTitle, +} from '../AboutPage.css' +import { builderFeatures } from '../data' +import { SectionIntro } from './SectionIntro' + +export const WhatIsBuilderSection: React.FC = () => { + return ( + + + + + {builderFeatures.map((item) => ( + + + + {item.marker} + + + {item.title} + + + + {item.body} + + + ))} + + + ) +} diff --git a/apps/web/src/modules/about/components/WhatYouCanBuildSection.tsx b/apps/web/src/modules/about/components/WhatYouCanBuildSection.tsx new file mode 100644 index 000000000..0051a388f --- /dev/null +++ b/apps/web/src/modules/about/components/WhatYouCanBuildSection.tsx @@ -0,0 +1,62 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + aboutDarkOnly, + aboutLightOnly, + compactValueCard, + compactValueEmoji, + compactValueImage, + compactValueTitle, + useCaseGrid, +} from '../AboutPage.css' +import { builderUseCases } from '../data' +import { SectionIntro } from './SectionIntro' + +export const WhatYouCanBuildSection: React.FC = () => { + return ( + + + + + {builderUseCases.map((item) => ( + + {item.id === 'media-collectives' ? ( + <> + + + + ) : item.imageSrc ? ( + + ) : item.emoji ? ( + {item.emoji} + ) : null} + + {item.title} + + + ))} + + + ) +} diff --git a/apps/web/src/modules/about/components/WhyBuilderSection.tsx b/apps/web/src/modules/about/components/WhyBuilderSection.tsx new file mode 100644 index 000000000..1be68577a --- /dev/null +++ b/apps/web/src/modules/about/components/WhyBuilderSection.tsx @@ -0,0 +1,118 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + featureIconBadge, + featureItem, + featureLabel, + featureStrip, + section, +} from '../AboutPage.css' +import { builderValueProps } from '../data' +import type { BuilderValueProp } from '../types' +import { SectionIntro } from './SectionIntro' + +type FeatureIcon = 'rocket' | 'treasury' | 'governance' | 'creative' + +const featureIconsById: Record = { + 'launch-fast': 'rocket', + 'treasury-auctions': 'treasury', + 'governance-day-one': 'governance', + 'creative-output': 'creative', +} + +const FeatureIconSvg = ({ icon }: { icon: FeatureIcon }) => { + switch (icon) { + case 'rocket': + return ( + + ) + case 'treasury': + return ( + + ) + case 'governance': + return ( + + ) + case 'creative': + return ( + + ) + } +} + +const FeatureItem = ({ item }: { item: BuilderValueProp }) => ( + + + + + + {item.title} + + +) + +export const WhyBuilderSection: React.FC = () => { + return ( + + + + + {builderValueProps.map((item) => ( + + ))} + + + ) +} diff --git a/apps/web/src/modules/about/data.ts b/apps/web/src/modules/about/data.ts new file mode 100644 index 000000000..1ea94ba78 --- /dev/null +++ b/apps/web/src/modules/about/data.ts @@ -0,0 +1,284 @@ +import { + ActivityItem, + BuilderFeature, + BuilderStep, + BuilderValueProp, + CoiningHighlight, + DroposalHighlight, +} from './types' + +// TODO: Replace seeded showcase data with an aggregated ecosystem endpoint once +// cross-DAO ranking and coining feeds are available for the public about page. +export const coiningHighlights: CoiningHighlight[] = [ + { + id: 'studio-coin', + title: 'Studio Coin', + creator: '0x7d4f...19ae', + dao: 'Studio Nouns', + chainLabel: 'Base', + amount: '$1.8M market cap', + href: '/explore?search=studio%20nouns', + eyebrow: 'DAO paired coin', + surface: 'linear-gradient(135deg, #F9E7FF 0%, #D9EEFF 100%)', + previewLabel: 'Creator pair', + }, + { + id: 'camp-coin', + title: 'Camp Coin', + creator: '0x4ac2...d6bf', + dao: 'Camp Nouns', + chainLabel: 'Base', + amount: '$940K market cap', + href: '/explore?search=camp%20nouns', + eyebrow: 'DAO paired coin', + surface: 'linear-gradient(135deg, #FFF0CC 0%, #FFD9BF 100%)', + previewLabel: 'Content pair', + }, + { + id: 'builder-coin', + title: 'Builder Coin', + creator: '0x8cf4...b2da', + dao: 'BuilderDAO', + chainLabel: 'Base', + amount: '$1.2M market cap', + href: '/explore?search=builderdao', + eyebrow: 'DAO paired coin', + surface: 'linear-gradient(135deg, #DFFFF0 0%, #D5F8FF 100%)', + previewLabel: 'Onchain market', + }, + { + id: 'fest-coin', + title: 'Fest Coin', + creator: '0x1ce9...42f0', + dao: 'Nouns Fest', + chainLabel: 'Ethereum', + amount: '$680K market cap', + href: '/explore?search=nouns%20fest', + eyebrow: 'DAO paired coin', + surface: 'linear-gradient(135deg, #E2EBFF 0%, #EDE8FF 100%)', + previewLabel: 'Content pair', + }, +] + +export const dropHighlights: DroposalHighlight[] = [ + { + id: 'open-edition-jam-drop', + title: 'Open Edition Jam', + dao: 'Studio Nouns', + amount: 'ETH 18.6 sold', + status: 'Live', + summary: + 'A live edition drop distributing creative work onchain with sales flowing to the DAO treasury.', + href: '/explore?search=studio%20nouns', + category: 'Base', + }, + { + id: 'summer-camp-poster-drop', + title: 'Summer Camp Poster Pack', + dao: 'Camp Nouns', + amount: 'ETH 9.4 sold', + status: 'Recent', + summary: + 'A collectible poster release used to distribute media and fund creative output through Builder.', + href: '/explore?search=camp%20nouns', + category: 'Base', + }, + { + id: 'governance-zine-drop', + title: 'Governance Zine 001', + dao: 'BuilderDAO', + amount: 'ETH 12.1 sold', + status: 'Recent', + summary: + 'An editorial drop that packages governance content into onchain ownership and treasury formation.', + href: '/explore?search=builderdao', + category: 'Base', + }, + { + id: 'signal-radio-drop', + title: 'Signal Radio Session', + dao: 'Nouns Fest', + amount: 'ETH 6.8 sold', + status: 'Live', + summary: + 'A media drop extending event programming into onchain distribution, ownership, and funding.', + href: '/explore?search=nouns%20fest', + category: 'Ethereum', + }, +] + +export const proposalHighlights: DroposalHighlight[] = [ + { + id: 'operator-tooling', + title: 'Operator tooling sprint for treasury reporting', + dao: 'BuilderDAO', + amount: '42 voters', + status: 'Queued', + summary: + 'Shared treasury analytics and operator tooling improve governance workflows across Builder DAOs.', + href: '/explore?search=builderdao', + category: 'Base', + }, + { + id: 'festival-grants', + title: 'Mini grants for local Nouns Fest activations', + dao: 'Nouns Fest', + amount: '18 voters', + status: 'Succeeded', + summary: + 'Regional teams get budget for activations, documentation, and post-event publishing.', + href: '/explore?search=nouns%20fest', + category: 'Ethereum', + }, + { + id: 'creator-retainer', + title: 'Quarterly creative retainer for media releases', + dao: 'Studio Nouns', + amount: '24 voters', + status: 'Active', + summary: + 'Sustains ongoing visual output with transparent milestones and release cadence.', + href: '/explore?search=studio%20nouns', + category: 'Base', + }, + { + id: 'builder-residency', + title: 'Residency round for new onchain builders', + dao: 'Far House', + amount: '30 voters', + status: 'Defeated', + summary: + 'Funds a cohort of builders experimenting with coining, drops, and governance flows.', + href: '/explore?search=far%20house', + category: 'Optimism', + }, + { + id: 'archive-pipeline', + title: 'Archive and indexing pipeline for DAO memory', + dao: 'Prop House', + amount: '12 voters', + status: 'Executed', + summary: + 'Creates searchable records for proposal outcomes, experiments, and funded work.', + href: '/explore?search=prop%20house', + category: 'Ethereum', + }, +] + +export const builderSteps: BuilderStep[] = [ + { + id: 'launch', + title: 'Launch your DAO', + body: 'Spin up a DAO with auctions, governance, and treasury built in.', + marker: '1', + }, + { + id: 'grow', + title: 'Grow your treasury', + body: 'Use auctions and coining to generate ongoing funding.', + marker: '2', + }, + { + id: 'fund', + title: 'Fund ideas', + body: 'Use proposals to allocate capital to builders, events, and experiments.', + marker: '3', + }, +] + +export const builderFeatures: BuilderFeature[] = [ + { + id: 'auctions', + marker: '1', + title: 'Auctions bring in new members and fund the treasury', + body: 'Ownership and capital contribution in one simple mechanic.', + }, + { + id: 'coining', + marker: '2', + title: 'Members participate in collaborative content creation', + body: 'Create content as coins and droposals to turn media into funding utilizing built in distribution methods.', + }, + { + id: 'proposals', + marker: '3', + title: 'The treasury funds ideas', + body: 'Members vote on proposals to fund work and allocate capital, all transparently onchain.', + }, +] + +export const builderValueProps: BuilderValueProp[] = [ + { + id: 'launch-fast', + title: 'Launch a DAO in minutes', + }, + { + id: 'treasury-auctions', + title: 'Built-in treasury formation through auctions', + }, + { + id: 'governance-day-one', + title: 'Governance included from day one', + }, + { + id: 'creative-output', + title: 'Native support for creative output', + }, +] + +export const ecosystemActivity: ActivityItem[] = [ + { + id: 'launches', + title: 'New launches keep expanding the graph', + detail: + 'Fresh DAOs continue showing up with tighter scopes: media, local communities, product teams, and events.', + meta: 'Recent launches', + tone: 'launch', + }, + { + id: 'coining', + title: 'Coining is broadening what a DAO can publish', + detail: + 'Posts are becoming funding surfaces for essays, visuals, audio, and experiments instead of just announcements.', + meta: 'Creator momentum', + tone: 'coin', + }, + { + id: 'droposals', + title: 'Droposals are routing treasury into clearer outcomes', + detail: + 'Teams are packaging work with tighter scope, deliverables, and faster governance cycles.', + meta: 'Governance patterns', + tone: 'governance', + }, + { + id: 'protocol', + title: 'Builder keeps shipping new primitives', + detail: + 'The protocol is evolving around creation flows, treasury tooling, content, and governance ergonomics.', + meta: 'Protocol motion', + tone: 'protocol', + }, +] + +export const heroHighlights = [ + 'Each new member joins through an auction.', + 'Every auction funds a shared treasury.', + 'Token holders propose and vote on how that capital is used.', +] + +export const builderUseCases: BuilderValueProp[] = [ + { + id: 'media-collectives', + title: 'Community owned brands', + imageSrc: '/noggles-square.svg', + }, + { id: 'creative-communities', title: 'Creative and media collectives', emoji: '🎨' }, + { id: 'events-local-groups', title: 'Local and event-based groups', emoji: '🎪' }, + { id: 'protocol-teams', title: 'Protocol teams', emoji: '⚙️' }, + { + id: 'experimental-governance', + title: 'Subculture communities', + emoji: '🛹', + }, +] diff --git a/apps/web/src/modules/about/index.ts b/apps/web/src/modules/about/index.ts new file mode 100644 index 000000000..3564b71bf --- /dev/null +++ b/apps/web/src/modules/about/index.ts @@ -0,0 +1 @@ +export * from './AboutPage' diff --git a/apps/web/src/modules/about/types.ts b/apps/web/src/modules/about/types.ts new file mode 100644 index 000000000..6798eb298 --- /dev/null +++ b/apps/web/src/modules/about/types.ts @@ -0,0 +1,108 @@ +export type AboutStat = { + id: string + label: string + value: string + detail: string + icon: string +} + +export type DaoCategory = 'featured' | 'trending' | 'new' | 'active' + +export type AboutDao = { + id: string + name: string + description: string + signalLabel: string + signalValue: string + href: string + badge: string + initials: string + imageUrl?: string | null + recentAuctionImage?: string | null + chainIcon?: string | null + chainName?: string | null + category: DaoCategory +} + +export type CoiningHighlight = { + id: string + title: string + creator: string + dao: string + chainLabel: string + amount: string + href: string + eyebrow: string + surface: string + previewLabel: string +} + +export type DroposalStatus = + | 'Active' + | 'Succeeded' + | 'Queued' + | 'Defeated' + | 'Executed' + | 'Trending' + | 'Live' + | 'Recent' + +export type DroposalHighlight = { + id: string + title: string + dao: string + amount: string + status: DroposalStatus + summary: string + href: string + category: string +} + +export type BuilderStep = { + id: string + title: string + body: string + marker: string +} + +export type BuilderValueProp = { + id: string + title: string + body?: string + emoji?: string + imageSrc?: string +} + +export type ActivityItem = { + id: string + title: string + detail: string + meta: string + tone: 'launch' | 'coin' | 'governance' | 'protocol' +} + +export type AboutDaoTabKey = 'featured' | 'trending' | 'new' | 'active' + +export type AboutDaoTabsResponse = { + featured: AboutDao[] + trending: AboutDao[] + new: AboutDao[] + active: AboutDao[] +} + +export type AboutSnapshotResponse = { + stats: AboutStat[] +} + +export type AboutShowcaseResponse = { + coining: CoiningHighlight[] + drops: DroposalHighlight[] + proposals: DroposalHighlight[] +} + +export type BuilderFeature = { + id: string + marker: string + title: string + body: string +} diff --git a/apps/web/src/modules/about/useAboutDaoTabs.ts b/apps/web/src/modules/about/useAboutDaoTabs.ts new file mode 100644 index 000000000..b74b20e26 --- /dev/null +++ b/apps/web/src/modules/about/useAboutDaoTabs.ts @@ -0,0 +1,60 @@ +import { SWR_KEYS } from '@buildeross/constants/swrKeys' +import useSWR from 'swr' + +import { AboutDaoTabsResponse } from './types' + +type HttpError = Error & { status?: number; body?: unknown } + +const fetcher = async ( + [, network]: readonly [string, string], + { signal }: { signal?: AbortSignal } = {} +): Promise => { + const response = await fetch( + `/api/about/dao-tabs?network=${encodeURIComponent(network)}`, + { + signal, + headers: { Accept: 'application/json' }, + } + ) + + const text = await response.text() + let body: unknown = {} + + try { + body = text ? JSON.parse(text) : {} + } catch { + body = text + } + + if (!response.ok) { + const err: HttpError = new Error( + typeof body === 'object' && body && 'error' in body + ? String((body as { error?: string }).error || response.statusText) + : response.statusText + ) + err.status = response.status + err.body = body + throw err + } + + return body as AboutDaoTabsResponse +} + +export const useAboutDaoTabs = (network?: string) => { + const swrKey = network ? ([SWR_KEYS.EXPLORE, network] as const) : null + + const { data, error, isLoading, isValidating } = useSWR< + AboutDaoTabsResponse, + HttpError + >(swrKey, fetcher, { + revalidateOnFocus: false, + revalidateOnReconnect: true, + dedupingInterval: 60000, + }) + + return { + data, + error, + isLoading: !!swrKey && (isLoading || isValidating), + } +} diff --git a/apps/web/src/modules/about/useAboutShowcase.ts b/apps/web/src/modules/about/useAboutShowcase.ts new file mode 100644 index 000000000..a92849c61 --- /dev/null +++ b/apps/web/src/modules/about/useAboutShowcase.ts @@ -0,0 +1,44 @@ +import useSWR from 'swr' + +import { AboutShowcaseResponse } from './types' + +type HttpError = Error & { status?: number; body?: unknown } + +const fetcher = async ( + [url]: readonly [string], + { signal }: { signal?: AbortSignal } = {} +): Promise => { + const response = await fetch(url, { + signal, + headers: { Accept: 'application/json' }, + }) + + const text = await response.text() + const body = text ? JSON.parse(text) : {} + + if (!response.ok) { + const err: HttpError = new Error(body?.error || response.statusText) + err.status = response.status + err.body = body + throw err + } + + return body as AboutShowcaseResponse +} + +export const useAboutShowcase = () => { + const { data, error, isLoading, isValidating } = useSWR< + AboutShowcaseResponse, + HttpError + >(['/api/about/showcase'] as const, fetcher, { + revalidateOnFocus: false, + revalidateOnReconnect: true, + dedupingInterval: 60000, + }) + + return { + data, + error, + isLoading: isLoading || isValidating, + } +} diff --git a/apps/web/src/modules/about/useAboutSnapshotStats.ts b/apps/web/src/modules/about/useAboutSnapshotStats.ts new file mode 100644 index 000000000..e1edd82d1 --- /dev/null +++ b/apps/web/src/modules/about/useAboutSnapshotStats.ts @@ -0,0 +1,54 @@ +import useSWR from 'swr' + +import { AboutSnapshotResponse } from './types' + +type HttpError = Error & { status?: number; body?: unknown } + +const fetcher = async ( + [url]: readonly [string], + { signal }: { signal?: AbortSignal } = {} +): Promise => { + const response = await fetch(url, { + signal, + headers: { Accept: 'application/json' }, + }) + + const text = await response.text() + let body: unknown = {} + + try { + body = text ? JSON.parse(text) : {} + } catch { + body = text + } + + if (!response.ok) { + const err: HttpError = new Error( + typeof body === 'object' && body && 'error' in body + ? String((body as { error?: string }).error || response.statusText) + : response.statusText + ) + err.status = response.status + err.body = body + throw err + } + + return body as AboutSnapshotResponse +} + +export const useAboutSnapshotStats = () => { + const { data, error, isLoading, isValidating } = useSWR< + AboutSnapshotResponse, + HttpError + >(['/api/about/snapshot'] as const, fetcher, { + revalidateOnFocus: false, + revalidateOnReconnect: true, + dedupingInterval: 60000, + }) + + return { + data, + error, + isLoading: isLoading || isValidating, + } +} diff --git a/apps/web/src/modules/about/utils.ts b/apps/web/src/modules/about/utils.ts new file mode 100644 index 000000000..68f890261 --- /dev/null +++ b/apps/web/src/modules/about/utils.ts @@ -0,0 +1,34 @@ +import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' +import removeMd from 'remove-markdown' + +// Normalize chain labels for fuzzy matching/deduplication: +// lowercase, replace non-alphanumerics with spaces, then collapse/trim whitespace. +const normalizeChainLabel = (value?: string | null) => + (value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .trim() + +const CHAIN_ICON_BY_LABEL = new Map( + PUBLIC_DEFAULT_CHAINS.flatMap((chain) => { + const labels = [chain.name, chain.slug].filter(Boolean).map(normalizeChainLabel) + const fallbackLabels = labels.flatMap((label) => { + if (!label.includes('sepolia')) return [] + + return [label.replace(/\s*sepolia$/, '').trim()] + }) + + return [...labels, ...fallbackLabels] + .filter(Boolean) + .map((label) => [label, chain.icon] as const) + }) +) + +export const getChainLogoSrc = (chainName?: string | null) => + CHAIN_ICON_BY_LABEL.get(normalizeChainLabel(chainName)) || null + +export const toPlainText = (value?: string | null) => + removeMd(value || '') + .replace(/\s+/g, ' ') + .trim() diff --git a/apps/web/src/modules/coin/CoinDetail/CoinCommentForm.css.ts b/apps/web/src/modules/coin/CoinDetail/CoinCommentForm.css.ts index c5171a9e3..b9f98c63f 100644 --- a/apps/web/src/modules/coin/CoinDetail/CoinCommentForm.css.ts +++ b/apps/web/src/modules/coin/CoinDetail/CoinCommentForm.css.ts @@ -4,7 +4,11 @@ import { style } from '@vanilla-extract/css' export const commentFormContainer = style([ atoms({ w: '100%', - backgroundColor: 'background2', + p: 'x4', + backgroundColor: 'background1', + borderColor: 'border', + borderStyle: 'solid', + borderWidth: 'thin', borderRadius: 'curved', mb: 'x5', }), diff --git a/apps/web/src/modules/coin/CoinDetail/CoinComments.css.ts b/apps/web/src/modules/coin/CoinDetail/CoinComments.css.ts index 33dd250c7..a55c94cff 100644 --- a/apps/web/src/modules/coin/CoinDetail/CoinComments.css.ts +++ b/apps/web/src/modules/coin/CoinDetail/CoinComments.css.ts @@ -18,7 +18,8 @@ export const commentCard = style([ borderRadius: 'curved', borderWidth: 'thin', borderStyle: 'solid', - borderColor: 'borderOnImage', + borderColor: 'border', + backgroundColor: 'background1', py: 'x2', px: 'x4', }), diff --git a/apps/web/src/modules/coin/CoinDetail/CoinComments.tsx b/apps/web/src/modules/coin/CoinDetail/CoinComments.tsx index 5238464ae..ce802c76b 100644 --- a/apps/web/src/modules/coin/CoinDetail/CoinComments.tsx +++ b/apps/web/src/modules/coin/CoinDetail/CoinComments.tsx @@ -56,7 +56,7 @@ const CommentCard: React.FC<{ nameStyle={{ fontSize: '16px' }} mobileTapBehavior="toggle" /> - + {timeAgo} diff --git a/apps/web/src/modules/coin/CoinDetail/CoinDetail.css.ts b/apps/web/src/modules/coin/CoinDetail/CoinDetail.css.ts index d3524b51c..240f799e5 100644 --- a/apps/web/src/modules/coin/CoinDetail/CoinDetail.css.ts +++ b/apps/web/src/modules/coin/CoinDetail/CoinDetail.css.ts @@ -41,6 +41,9 @@ export const coinInfoPanel = style([ p: 'x6', borderRadius: 'curved', backgroundColor: 'background2', + borderColor: 'border', + borderWidth: 'thin', + borderStyle: 'solid', }), ]) @@ -49,6 +52,9 @@ export const swapPanel = style([ p: 'x6', borderRadius: 'curved', backgroundColor: 'background2', + borderColor: 'border', + borderWidth: 'thin', + borderStyle: 'solid', }), { '@media': { diff --git a/apps/web/src/modules/dashboard/CreateActions.css.ts b/apps/web/src/modules/dashboard/CreateActions.css.ts index e42d1142f..35328a602 100644 --- a/apps/web/src/modules/dashboard/CreateActions.css.ts +++ b/apps/web/src/modules/dashboard/CreateActions.css.ts @@ -1,8 +1,5 @@ -import { vars } from '@buildeross/zord' import { style } from '@vanilla-extract/css' -const darkButtonHover = vars.color.neutralHover - export const actionButtons = style({ width: '100%', '@media': { @@ -11,16 +8,3 @@ export const actionButtons = style({ }, }, }) - -export const daoButton = style({ - borderColor: `${vars.color.border} !important`, -}) - -export const createPostButton = style({ - selectors: { - 'html[data-theme-mode="dark"] &:hover': { - backgroundColor: `${darkButtonHover} !important`, - borderColor: `${darkButtonHover} !important`, - }, - }, -}) diff --git a/apps/web/src/modules/dashboard/CreateActions.tsx b/apps/web/src/modules/dashboard/CreateActions.tsx index 6bbcd7e6c..cf59ab9d4 100644 --- a/apps/web/src/modules/dashboard/CreateActions.tsx +++ b/apps/web/src/modules/dashboard/CreateActions.tsx @@ -3,7 +3,7 @@ import { Button, Flex } from '@buildeross/zord' import Link from 'next/link' import React, { useState } from 'react' -import { actionButtons, createPostButton, daoButton } from './CreateActions.css' +import { actionButtons } from './CreateActions.css' import { DaoSelectorModal } from './DaoSelectorModal' export interface CreateActionsProps { @@ -28,23 +28,21 @@ export const CreateActions: React.FC = ({ userAddress }) => <> - - - - + { dao.proposals.some( (proposal) => proposal.state === ProposalState.Active && - !proposal.votes.some((vote) => vote.voter === address.toLowerCase()) + !proposal.votes.some( + (vote: { voter: string }) => vote.voter === address.toLowerCase() + ) ) ) }, [sortedDaos, address]) diff --git a/apps/web/src/modules/dashboard/SingleDaoSelector.tsx b/apps/web/src/modules/dashboard/SingleDaoSelector.tsx index 20ac9a96a..2ce2bd70e 100644 --- a/apps/web/src/modules/dashboard/SingleDaoSelector.tsx +++ b/apps/web/src/modules/dashboard/SingleDaoSelector.tsx @@ -1,6 +1,7 @@ import { COIN_SUPPORTED_CHAIN_IDS, PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants' import { SearchInput } from '@buildeross/feed-ui' import { useDaoSearch, useDaosWithClankerTokens, useUserDaos } from '@buildeross/hooks' +import type { MyDaosResponse } from '@buildeross/sdk/subgraph' import type { AddressType, CHAIN_ID, @@ -40,6 +41,7 @@ export interface SingleDaoSelectorProps { } const MIN_SEARCH_LENGTH = 3 +type MemberDao = MyDaosResponse[number] export const SingleDaoSelector: React.FC = ({ chainIds, @@ -60,7 +62,7 @@ export const SingleDaoSelector: React.FC = ({ // Convert MyDaosResponse to DaoListItem format (before filtering) const allDaoItems: DaoListItem[] = useMemo(() => { - let items = memberDaos.map((dao) => ({ + let items: DaoListItem[] = memberDaos.map((dao: MemberDao) => ({ name: dao.name, image: dao.contractImage, address: dao.collectionAddress.toLowerCase() as AddressType, @@ -84,13 +86,13 @@ export const SingleDaoSelector: React.FC = ({ // Group DAOs by chain for checking clanker tokens const daosByChain = useMemo(() => { - const grouped: Record = {} as Record + const grouped: Partial> = {} allDaoItems.forEach((dao) => { if (isChainIdSupportedByCoining(dao.chainId)) { if (!grouped[dao.chainId]) { grouped[dao.chainId] = [] } - grouped[dao.chainId].push(dao) + grouped[dao.chainId]!.push(dao) } }) return grouped @@ -98,11 +100,11 @@ export const SingleDaoSelector: React.FC = ({ // Fetch clanker tokens for each chain const chainQueries = COIN_SUPPORTED_CHAIN_IDS.map((chainId) => { - const daosOnChain = daosByChain[chainId] || [] + const daosOnChain: DaoListItem[] = daosByChain[chainId as CHAIN_ID] || [] // eslint-disable-next-line react-hooks/rules-of-hooks return useDaosWithClankerTokens({ chainId, - daoAddresses: daosOnChain.map((dao) => dao.address), + daoAddresses: daosOnChain.map((dao: DaoListItem) => dao.address), enabled: actionType === 'post' && daosOnChain.length > 0, }) }) diff --git a/apps/web/src/modules/drop/DropDetail/DropDetail.css.ts b/apps/web/src/modules/drop/DropDetail/DropDetail.css.ts index a2251b0a6..309afcc24 100644 --- a/apps/web/src/modules/drop/DropDetail/DropDetail.css.ts +++ b/apps/web/src/modules/drop/DropDetail/DropDetail.css.ts @@ -41,12 +41,20 @@ export const dropInfoPanel = style([ p: 'x6', borderRadius: 'curved', backgroundColor: 'background2', + borderColor: 'border', + borderWidth: 'thin', + borderStyle: 'solid', }), ]) export const mintPanel = style([ atoms({ + p: 'x6', borderRadius: 'curved', + backgroundColor: 'background2', + borderColor: 'border', + borderWidth: 'thin', + borderStyle: 'solid', }), { '@media': { diff --git a/apps/web/src/modules/drop/DropDetail/DropDetail.tsx b/apps/web/src/modules/drop/DropDetail/DropDetail.tsx index 0d584d6bd..6f2638890 100644 --- a/apps/web/src/modules/drop/DropDetail/DropDetail.tsx +++ b/apps/web/src/modules/drop/DropDetail/DropDetail.tsx @@ -122,6 +122,7 @@ export const DropDetail = ({ saleEnd={saleEnd} editionSize={drop.editionSize} maxPerAddress={parseInt(drop.maxSalePurchasePerAddress)} + unstyledContainer /> @@ -167,6 +168,7 @@ export const DropDetail = ({ saleEnd={saleEnd} editionSize={drop.editionSize} maxPerAddress={parseInt(drop.maxSalePurchasePerAddress)} + unstyledContainer /> diff --git a/apps/web/src/pages/about.tsx b/apps/web/src/pages/about.tsx index 773bf8085..bc2f09451 100644 --- a/apps/web/src/pages/about.tsx +++ b/apps/web/src/pages/about.tsx @@ -1,98 +1,16 @@ -import { useChainStore } from '@buildeross/stores' -import { ContractButton } from '@buildeross/ui/ContractButton' -import { Box, Stack, vars } from '@buildeross/zord' import Head from 'next/head' -import Image from 'next/image' -import Link from 'next/link' -import { useRouter } from 'next/router' -import React, { useCallback } from 'react' import { getDefaultLayout } from 'src/layouts/DefaultLayout' -import { darkWhy, lightWhy, whyCreateButton, whyTextStyle } from 'src/styles/about.css' +import { AboutPageView } from 'src/modules/about' import { NextPageWithLayout } from './_app' const AboutPage: NextPageWithLayout = () => { - const { push } = useRouter() - const { id: chainId } = useChainStore((x) => x.chain) - - const handleCreateClick = useCallback(() => { - push('/create') - }, [push]) - return ( <> Nouns Builder | About - - - - why - {''} - - - Nouns Builder makes it easy for communities and collectives to create Nounish - DAOs, fully equipped with onchain governance and membership auctions starting - day one. - - - This public good DAO tooling and the Nouns Builder Protocol are maintained and - governed by   - - BuilderDAO - - . Learn more about the DAO's vision and mission   - - here - - . - - - - Create a DAO - - - + ) } diff --git a/apps/web/src/pages/api/about/dao-tabs.ts b/apps/web/src/pages/api/about/dao-tabs.ts new file mode 100644 index 000000000..b9834c1db --- /dev/null +++ b/apps/web/src/pages/api/about/dao-tabs.ts @@ -0,0 +1,613 @@ +import { CACHE_TIMES } from '@buildeross/constants/cacheTimes' +import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' +import { PUBLIC_SUBGRAPH_URL } from '@buildeross/constants/subgraph' +import { Auction_OrderBy, exploreDaosRequest } from '@buildeross/sdk/subgraph' +import { CHAIN_ID } from '@buildeross/types' +import { chainIdToSlug } from '@buildeross/utils/chains' +import { gql, GraphQLClient } from 'graphql-request' +import type { NextApiRequest, NextApiResponse } from 'next' +import { summarizeDaoDescription } from 'src/utils/api/ai/summaries' +import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' +import { parseEther } from 'viem' + +import type { AboutDao, AboutDaoTabsResponse } from '../../../modules/about/types' + +const MIN_AUCTION_SALES = parseEther('0.001').toString() +const THIRTY_DAYS_IN_SECONDS = 60 * 60 * 24 * 30 + +type DaoQueryItem = { + id: string + name: string + description: string + tokenAddress: string + contractImage?: string | null + totalSupply: number + totalAuctionSales: string + latestToken: Array<{ name: string; image?: string | null; tokenId: string }> + firstToken: Array<{ mintedAt: string }> + recentAuctions: Array<{ + id: string + endTime: string + winningBid?: { amount: string } | null + }> + currentAuction?: { + endTime: string + highestBid?: { amount: string } | null + token?: { tokenId: string; name: string; image?: string | null } | null + } | null +} + +type DaoQueryResponse = { + daos: DaoQueryItem[] +} + +type CrossChainDaoCandidate = { + chainId: CHAIN_ID + dao: DaoQueryItem +} + +type CrossChainActiveDao = { + chainId: CHAIN_ID + dao: NonNullable>>['daos'][number] +} + +type SettledChainResult = { + chainId: CHAIN_ID + value: T +} + +const getFulfilledChainResults = ( + settledResults: PromiseSettledResult>[], + context: string +) => + settledResults.flatMap((result) => { + if (result.status === 'fulfilled') { + return [result.value.value] + } + + const reason = result.reason as { chainId?: CHAIN_ID; error?: unknown } + console.error( + `About dao tabs ${context} failed for chain ${String(reason?.chainId ?? 'unknown')}:`, + reason?.error || reason + ) + return [] + }) + +type FeaturedDaoConfigItem = { + name: string + aliases?: readonly string[] + description: string + signalLabel: string + signalValue: string + fallbackChainId: CHAIN_ID +} + +const ABOUT_DAO_TABS_QUERY = gql` + query AboutDaoTabs( + $daoFirst: Int! + $daoWhere: DAO_filter + $recentAuctionWhere: Auction_filter + $recentAuctionFirst: Int! + ) { + daos( + first: $daoFirst + where: $daoWhere + orderBy: totalAuctionSales + orderDirection: desc + ) { + id + name + description + tokenAddress + contractImage + totalSupply + totalAuctionSales + latestToken: tokens(first: 1, orderBy: mintedAt, orderDirection: desc) { + name + image + tokenId + } + firstToken: tokens(first: 1, orderBy: mintedAt, orderDirection: asc) { + mintedAt + } + currentAuction { + endTime + highestBid { + amount + } + token { + tokenId + name + image + } + } + recentAuctions: auctions( + first: $recentAuctionFirst + orderBy: endTime + orderDirection: desc + where: $recentAuctionWhere + ) { + id + endTime + winningBid { + amount + } + } + } + } +` + +const buildDaoHref = (chainId: CHAIN_ID, tokenAddress: string) => + `/dao/${chainIdToSlug(chainId)}/${tokenAddress}` + +const getInitials = (name: string) => + name + .split(' ') + .slice(0, 2) + .map((part) => part[0]?.toUpperCase() ?? '') + .join('') + +const FEATURED_DAO_CONFIG: readonly FeaturedDaoConfigItem[] = [ + { + name: 'Builder DAO', + aliases: ['BuilderDAO'], + description: 'Stewards Builder upgrades, ecosystem tooling, and protocol operations.', + signalLabel: 'Role', + signalValue: 'Protocol steward', + fallbackChainId: CHAIN_ID.BASE, + }, + { + name: 'Gnars DAO', + aliases: ['Gnars'], + description: 'Funds skate, surf, and snow culture through auctions and governance.', + signalLabel: 'Focus', + signalValue: 'Sports culture', + fallbackChainId: CHAIN_ID.BASE, + }, + { + name: 'Collective Nouns', + description: 'Explores shared ownership and member-led treasury deployment.', + signalLabel: 'Focus', + signalValue: 'Art collective', + fallbackChainId: CHAIN_ID.BASE, + }, + { + name: 'Nouns DAO Africa', + description: 'Supports regional builders and creatives using Nounish governance.', + signalLabel: 'Focus', + signalValue: 'Regional empowerment', + fallbackChainId: CHAIN_ID.ETHEREUM, + }, +] as const + +const chainById = new Map( + PUBLIC_DEFAULT_CHAINS.map((chain) => [chain.id, chain] as const) +) + +const normalizeDaoName = (name: string) => + name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, '') + +const isNameMatch = (targets: string[], candidateName?: string | null) => { + const candidate = normalizeDaoName(candidateName || '') + + if (!candidate) return false + + return targets.some((target) => { + const normalizedTarget = normalizeDaoName(target) + + return ( + candidate === normalizedTarget || + candidate.includes(normalizedTarget) || + normalizedTarget.includes(candidate) + ) + }) +} + +const shortenDescription = (description?: string | null) => { + const base = description?.trim() + + if (!base) return null + + const [firstSentence] = base.split(/(?<=[.!?])\s+/) + const concise = firstSentence || base + + return concise.length > 110 ? `${concise.slice(0, 107).trimEnd()}...` : concise +} + +const createDaoDescriptionResolver = () => { + const summaries = new Map>() + const aiConfigured = Boolean( + process.env.AI_GATEWAY_API_KEY || process.env.OPENAI_API_KEY + ) + + return (description?: string | null) => { + const base = description?.trim() + + if (!base) { + return Promise.resolve(null) + } + + if (!aiConfigured) { + return Promise.resolve(shortenDescription(base)) + } + + if (!summaries.has(base)) { + summaries.set( + base, + summarizeDaoDescription(base) + .then((summary) => summary || shortenDescription(base)) + .catch(() => shortenDescription(base)) + ) + } + + return summaries.get(base)! + } +} + +const mapDao = ( + dao: DaoQueryItem, + chainId: CHAIN_ID, + _index: number, + signalLabel: string, + signalValue: string, + badge: string, + category: AboutDao['category'], + descriptionOverride?: string | null +): AboutDao => ({ + id: `${dao.id}-${badge.toLowerCase()}`, + name: dao.name || 'Untitled DAO', + description: + descriptionOverride || + shortenDescription(dao.description) || + 'Live Builder DAO with recent auction and member activity.', + signalLabel, + signalValue, + href: buildDaoHref(chainId, dao.tokenAddress), + badge, + initials: getInitials(dao.name || 'DAO'), + imageUrl: dao.contractImage || dao.latestToken[0]?.image || null, + recentAuctionImage: + dao.currentAuction?.token?.image || dao.latestToken[0]?.image || null, + chainIcon: chainById.get(chainId)?.icon || null, + chainName: chainById.get(chainId)?.name || null, + category, +}) + +const fetchDaoCandidates = async ( + chainId: CHAIN_ID, + options: { totalAuctionSalesGt?: string; first?: number; nameContains?: string } = {} +): Promise => { + const subgraphUrl = PUBLIC_SUBGRAPH_URL.get(chainId) + + if (!subgraphUrl) { + throw new Error(`No subgraph URL configured for chain ${chainId}`) + } + + const client = new GraphQLClient(subgraphUrl, { + headers: { 'Content-Type': 'application/json' }, + }) + + const now = Math.floor(Date.now() / 1000) + + const data = await client.request(ABOUT_DAO_TABS_QUERY, { + daoFirst: options.first ?? 150, + daoWhere: { + totalSupply_gt: 0, + ...(options.nameContains ? { name_contains_nocase: options.nameContains } : {}), + ...(options.totalAuctionSalesGt + ? { totalAuctionSales_gt: options.totalAuctionSalesGt } + : {}), + }, + recentAuctionFirst: 40, + recentAuctionWhere: { + settled: true, + endTime_gt: now - THIRTY_DAYS_IN_SECONDS, + }, + }) + + return data.daos ?? [] +} + +const fetchCrossChainDaoCandidates = async ( + options: { totalAuctionSalesGt?: string; first?: number } = {} +): Promise => { + const settledResults = await Promise.allSettled( + PUBLIC_DEFAULT_CHAINS.map(async (chain) => { + try { + const daos = await fetchDaoCandidates(chain.id, options) + + return { + chainId: chain.id, + value: daos.map((dao) => ({ chainId: chain.id, dao })), + } + } catch (error) { + throw { chainId: chain.id, error } + } + }) + ) + + return getFulfilledChainResults(settledResults, 'candidate fetch').flat() +} + +const findFeaturedMatch = (targets: string[], candidates: CrossChainDaoCandidate[]) => + candidates.find((item) => isNameMatch(targets, item.dao.name)) + +const fetchFeaturedMatch = async (targets: string[]) => { + const settledResults = await Promise.allSettled( + PUBLIC_DEFAULT_CHAINS.flatMap((chain) => + targets.map(async (target) => { + try { + const daos = await fetchDaoCandidates(chain.id, { + first: 25, + nameContains: target, + }) + + return { + chainId: chain.id, + value: daos.map((dao) => ({ chainId: chain.id, dao })), + } + } catch (error) { + throw { chainId: chain.id, error } + } + }) + ) + ) + + return findFeaturedMatch( + targets, + getFulfilledChainResults(settledResults, 'featured match fetch').flat() + ) +} + +const buildFeaturedDaos = async ( + resolveDescription: ReturnType +): Promise => { + const settledResults = await Promise.allSettled( + PUBLIC_DEFAULT_CHAINS.map(async (chain) => { + try { + const daos = await fetchDaoCandidates(chain.id, { first: 800 }) + + return { + chainId: chain.id, + value: daos.map((dao) => ({ chainId: chain.id, dao })), + } + } catch (error) { + throw { chainId: chain.id, error } + } + }) + ) + + const featuredCandidates = getFulfilledChainResults( + settledResults, + 'featured candidate fetch' + ).flat() + + return Promise.all( + FEATURED_DAO_CONFIG.map(async (item, index) => { + const targets = [item.name, ...(item.aliases ?? [])] + const match = + findFeaturedMatch(targets, featuredCandidates) || + (await fetchFeaturedMatch(targets)) + + if (!match) { + return { + id: `${normalizeDaoName(item.name).replace(/\s+/g, '-')}-featured`, + name: item.name, + description: item.description, + signalLabel: item.signalLabel, + signalValue: item.signalValue, + href: '/explore', + badge: 'Featured', + initials: getInitials(item.name), + imageUrl: null, + recentAuctionImage: null, + chainIcon: chainById.get(item.fallbackChainId)?.icon || null, + chainName: chainById.get(item.fallbackChainId)?.name || null, + category: 'featured' as const, + } + } + + const description = + (await resolveDescription(match.dao.description || item.description)) || + shortenDescription(match.dao.description || item.description) + + return { + ...mapDao( + { + ...match.dao, + description: match.dao.description || item.description, + }, + match.chainId, + index, + item.signalLabel, + item.signalValue, + 'Featured', + 'featured', + description + ), + name: item.name, + } + }) + ) +} + +const buildTrendingDaos = async ( + items: CrossChainDaoCandidate[], + resolveDescription: ReturnType +) => + Promise.all( + items + .map((item) => ({ + ...item, + recentSalesCount: item.dao.recentAuctions.length, + recentVolume: item.dao.recentAuctions.reduce( + (total, auction) => total + BigInt(auction.winningBid?.amount ?? '0'), + 0n + ), + })) + .filter((item) => item.recentSalesCount > 0) + .sort((a, b) => { + if (b.recentSalesCount !== a.recentSalesCount) { + return b.recentSalesCount - a.recentSalesCount + } + if (b.recentVolume === a.recentVolume) { + return 0 + } + return b.recentVolume > a.recentVolume ? 1 : -1 + }) + .slice(0, 4) + .map(async (item, index) => + mapDao( + item.dao, + item.chainId, + index, + 'Sales in 30d', + `${item.recentSalesCount} settled`, + 'Trending', + 'trending', + (await resolveDescription(item.dao.description)) || + shortenDescription(item.dao.description) + ) + ) + ) + +const buildNewDaos = async ( + items: CrossChainDaoCandidate[], + resolveDescription: ReturnType +) => + Promise.all( + items + .filter((item) => item.dao.totalSupply > 3 && item.dao.firstToken[0]?.mintedAt) + .sort( + (a, b) => + Number(b.dao.firstToken[0].mintedAt) - Number(a.dao.firstToken[0].mintedAt) + ) + .slice(0, 4) + .map(async (item, index) => + mapDao( + item.dao, + item.chainId, + index, + 'Supply sold', + `${item.dao.totalSupply} tokens`, + 'New', + 'new', + (await resolveDescription(item.dao.description)) || + shortenDescription(item.dao.description) + ) + ) + ) + +const buildActiveDaos = async ( + daoCandidates: CrossChainDaoCandidate[], + resolveDescription: ReturnType +) => { + const now = Math.floor(Date.now() / 1000) + const settledResults = await Promise.allSettled( + PUBLIC_DEFAULT_CHAINS.map(async (chain) => { + try { + const result = await exploreDaosRequest(chain.id, 12, 0, Auction_OrderBy.EndTime) + + return { + chainId: chain.id, + value: (result?.daos ?? []).map((dao) => ({ + chainId: chain.id, + dao, + })), + } + } catch (error) { + throw { chainId: chain.id, error } + } + }) + ) + + const activeDaos = getFulfilledChainResults( + settledResults, + 'active dao fetch' + ).flat() as CrossChainActiveDao[] + + return Promise.all( + activeDaos + .filter((item) => item.dao.endTime && Number(item.dao.endTime) > now) + .sort((a, b) => Number(a.dao.endTime) - Number(b.dao.endTime)) + .slice(0, 4) + .map(async (item) => { + const matchingDao = daoCandidates.find( + (candidate) => + candidate.chainId === item.chainId && + candidate.dao.tokenAddress.toLowerCase() === + item.dao.dao.tokenAddress.toLowerCase() + ) + + const descriptionSource = matchingDao?.dao.description + const description = + (await resolveDescription(descriptionSource)) || + shortenDescription(descriptionSource) || + 'Live Builder DAO with recent auction and member activity.' + + return { + id: `${item.chainId}-${item.dao.dao.tokenAddress}-active`, + name: item.dao.dao.name || item.dao.token?.name || 'Untitled DAO', + description, + signalLabel: 'Ends soon', + signalValue: `${Math.max(1, Math.round((Number(item.dao.endTime) - now) / 3600))}h left`, + href: buildDaoHref(item.chainId, item.dao.dao.tokenAddress), + badge: 'Active', + initials: getInitials(item.dao.dao.name || item.dao.token?.name || 'DAO'), + imageUrl: matchingDao?.dao.contractImage || item.dao.token?.image || null, + recentAuctionImage: item.dao.token?.image || null, + chainIcon: chainById.get(item.chainId)?.icon || null, + chainName: chainById.get(item.chainId)?.name || null, + category: 'active' as const, + } + }) + ) +} + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + const resolveDescription = createDaoDescriptionResolver() + + const [featuredDaos, daoCandidates] = await Promise.all([ + buildFeaturedDaos(resolveDescription), + fetchCrossChainDaoCandidates({ + totalAuctionSalesGt: MIN_AUCTION_SALES, + first: 150, + }), + ]) + + const payload: AboutDaoTabsResponse = { + featured: featuredDaos, + trending: await buildTrendingDaos(daoCandidates, resolveDescription), + new: await buildNewDaos(daoCandidates, resolveDescription), + active: await buildActiveDaos(daoCandidates, resolveDescription), + } + + const { maxAge, swr } = CACHE_TIMES.EXPLORE + res.setHeader( + 'Cache-Control', + `public, s-maxage=${maxAge}, stale-while-revalidate=${swr}` + ) + + return res.status(200).json(payload) + } catch (error) { + console.error('About dao tabs error:', error) + return res.status(500).json({ error: 'Failed to load about page DAO tabs' }) + } +} + +export default withCors()( + withRateLimit({ + maxRequests: 60, + windowSeconds: 60, + keyPrefix: 'about:dao-tabs', + })(handler) +) diff --git a/apps/web/src/pages/api/about/showcase.ts b/apps/web/src/pages/api/about/showcase.ts new file mode 100644 index 000000000..a67f5d49a --- /dev/null +++ b/apps/web/src/pages/api/about/showcase.ts @@ -0,0 +1,416 @@ +import { CACHE_TIMES } from '@buildeross/constants/cacheTimes' +import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' +import { PUBLIC_SUBGRAPH_URL } from '@buildeross/constants/subgraph' +import { CHAIN_ID } from '@buildeross/types' +import { DEFAULT_ZORA_TOTAL_SUPPLY, walletSnippet } from '@buildeross/utils' +import { chainIdToSlug } from '@buildeross/utils/chains' +import { gql, GraphQLClient } from 'graphql-request' +import type { NextApiRequest, NextApiResponse } from 'next' +import { getZoraCoinUsdPrice } from 'src/services/coinPriceService' +import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' +import { formatEther } from 'viem' + +import { + coiningHighlights as fallbackCoiningHighlights, + dropHighlights as fallbackDropHighlights, + proposalHighlights as fallbackProposalHighlights, +} from '../../../modules/about/data' +import type { + AboutShowcaseResponse, + CoiningHighlight, + DroposalHighlight, + DroposalStatus, +} from '../../../modules/about/types' + +type ShowcaseZoraDrop = { + id: string + name: string + description?: string | null + imageURI?: string | null + animationURI?: string | null + creator: string + totalSalesAmount: string + createdAt: string + dao?: { + name: string + tokenAddress: string + } | null +} + +type ShowcaseZoraCoin = { + id: string + coinAddress: string + name: string + symbol: string + caller: string + createdAt: string + dao?: { + name: string + tokenAddress: string + } | null +} + +type ShowcaseProposal = { + id: string + proposalId: string + proposalNumber: number + title?: string | null + description?: string | null + timeCreated: string + voteEnd: string + voteCount: number + forVotes: number + againstVotes: number + quorumVotes: string + queued: boolean + executed: boolean + canceled: boolean + vetoed: boolean + dao: { + name: string + tokenAddress: string + } +} + +type ShowcaseQueryResponse = { + zoraCoins: ShowcaseZoraCoin[] + zoraDrops: ShowcaseZoraDrop[] + proposals: ShowcaseProposal[] +} + +type CrossChainCoin = ShowcaseZoraCoin & { chainId: CHAIN_ID } +type CrossChainDrop = ShowcaseZoraDrop & { chainId: CHAIN_ID } +type CrossChainProposal = ShowcaseProposal & { chainId: CHAIN_ID } + +const ABOUT_SHOWCASE_QUERY = gql` + query AboutShowcase($coinFirst: Int!, $dropFirst: Int!, $proposalFirst: Int!) { + zoraCoins( + first: $coinFirst + orderBy: createdAt + orderDirection: desc + where: { dao_not: null } + ) { + id + coinAddress + name + symbol + caller + createdAt + dao { + name + tokenAddress + } + } + zoraDrops( + first: $dropFirst + orderBy: totalSalesAmount + orderDirection: desc + where: { dao_not: null, totalSalesAmount_gt: 0 } + ) { + id + name + description + imageURI + animationURI + creator + totalSalesAmount + createdAt + dao { + name + tokenAddress + } + } + proposals( + first: $proposalFirst + orderBy: timeCreated + orderDirection: desc + where: { title_not: null } + ) { + id + proposalId + proposalNumber + title + description + timeCreated + voteEnd + voteCount + forVotes + againstVotes + quorumVotes + queued + executed + canceled + vetoed + dao { + name + tokenAddress + } + } + } +` + +const compactVotes = (votes: number) => + `${new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }).format(votes)} voters` + +const compactMarketCap = (marketCapUsd: number) => + `$${new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }).format(marketCapUsd)} market cap` + +const compactEthAmount = (amountInEth: number) => + `ETH ${new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }).format(amountInEth)} sold` + +const previewSurfaces = [ + 'linear-gradient(135deg, #F9E7FF 0%, #D9EEFF 100%)', + 'linear-gradient(135deg, #FFF0CC 0%, #FFD9BF 100%)', + 'linear-gradient(135deg, #DFFFF0 0%, #D5F8FF 100%)', + 'linear-gradient(135deg, #E2EBFF 0%, #EDE8FF 100%)', +] + +const cleanSentence = (value?: string | null) => { + const base = value + ?.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ') + ?.replace(/\[[^\]]+\]\([^)]+\)/g, ' ') + ?.replace(/[`*_>#-]/g, ' ') + ?.replace(/\s+/g, ' ') + ?.trim() + + if (!base) return null + + const [firstSentence] = base.split(/(?<=[.!?])\s+/) + const concise = firstSentence || base + + return concise.length > 120 ? `${concise.slice(0, 117).trimEnd()}...` : concise +} + +const deriveProposalStatus = ( + proposal: ShowcaseProposal, + now: number +): DroposalStatus | null => { + if (proposal.canceled || proposal.vetoed) return null + if (proposal.executed) return 'Executed' + if (proposal.queued) return 'Queued' + if (Number(proposal.voteEnd) > now) return 'Active' + + const passed = + proposal.forVotes > proposal.againstVotes && + BigInt(proposal.forVotes) >= BigInt(proposal.quorumVotes) + + return passed ? 'Succeeded' : 'Defeated' +} + +const fetchShowcaseDataForChain = async ( + chainId: CHAIN_ID +): Promise<{ + coins: CrossChainCoin[] + drops: CrossChainDrop[] + proposals: CrossChainProposal[] +}> => { + const subgraphUrl = PUBLIC_SUBGRAPH_URL.get(chainId) + + if (!subgraphUrl) { + return { coins: [], drops: [], proposals: [] } + } + + const client = new GraphQLClient(subgraphUrl, { + headers: { 'Content-Type': 'application/json' }, + }) + + const data = await client.request(ABOUT_SHOWCASE_QUERY, { + coinFirst: 30, + dropFirst: 12, + proposalFirst: 20, + }) + + return { + coins: (data.zoraCoins ?? []).map((coin) => ({ ...coin, chainId })), + drops: (data.zoraDrops ?? []).map((drop) => ({ ...drop, chainId })), + proposals: (data.proposals ?? []).map((proposal) => ({ ...proposal, chainId })), + } +} + +const buildCoiningHighlights = async ( + coins: CrossChainCoin[] +): Promise => { + const coinsWithMarketCap = await Promise.all( + coins + .filter((coin) => coin.dao?.tokenAddress && coin.coinAddress && coin.name) + .map(async (coin) => { + const priceUsd = await getZoraCoinUsdPrice(coin.coinAddress, coin.chainId).catch( + () => null + ) + + const marketCapUsd = + priceUsd && Number.isFinite(priceUsd) + ? priceUsd * DEFAULT_ZORA_TOTAL_SUPPLY + : null + + return { + coin, + marketCapUsd, + } + }) + ) + + return coinsWithMarketCap + .filter( + ( + item + ): item is { + coin: CrossChainCoin + marketCapUsd: number + } => item.marketCapUsd !== null && item.marketCapUsd > 0 + ) + .sort((a, b) => b.marketCapUsd - a.marketCapUsd) + .slice(0, 4) + .map(({ coin, marketCapUsd }, index) => ({ + id: `${coin.chainId}-${coin.id}`, + title: coin.name, + creator: walletSnippet(coin.caller as `0x${string}`), + dao: coin.dao?.name || 'Builder DAO', + chainLabel: + PUBLIC_DEFAULT_CHAINS.find((chain) => chain.id === coin.chainId)?.name || 'Base', + amount: compactMarketCap(marketCapUsd), + href: `/coin/${chainIdToSlug(coin.chainId)}/${coin.coinAddress}`, + eyebrow: 'DAO paired coin', + surface: previewSurfaces[index % previewSurfaces.length], + previewLabel: coin.symbol || 'Content coin', + })) +} + +const buildDropHighlights = (drops: CrossChainDrop[]): DroposalHighlight[] => { + const now = Math.floor(Date.now() / 1000) + + return drops + .filter((drop) => drop.dao?.tokenAddress && drop.name) + .sort((a, b) => { + const aAmount = BigInt(a.totalSalesAmount || '0') + const bAmount = BigInt(b.totalSalesAmount || '0') + return bAmount > aAmount ? 1 : bAmount < aAmount ? -1 : 0 + }) + .slice(0, 5) + .map((drop) => ({ + id: `${drop.chainId}-${drop.id}`, + title: drop.name, + dao: drop.dao?.name || 'Builder DAO', + amount: compactEthAmount(Number(formatEther(BigInt(drop.totalSalesAmount || '0')))), + status: Number(drop.createdAt) > now - 60 * 60 * 24 * 14 ? 'Live' : 'Recent', + summary: + cleanSentence(drop.description) || + 'A live onchain drop distributing content through Builder.', + href: `/drop/${chainIdToSlug(drop.chainId)}/${drop.id}`, + category: + PUBLIC_DEFAULT_CHAINS.find((chain) => chain.id === drop.chainId)?.name || + 'Onchain drop', + })) +} + +const buildProposalHighlights = ( + proposals: CrossChainProposal[] +): DroposalHighlight[] => { + const now = Math.floor(Date.now() / 1000) + + return proposals + .map((proposal) => ({ + proposal, + status: deriveProposalStatus(proposal, now), + })) + .filter( + ( + item + ): item is { + proposal: CrossChainProposal + status: DroposalStatus + } => Boolean(item.status) + ) + .sort((a, b) => { + if (a.status === 'Active' && b.status !== 'Active') return -1 + if (a.status !== 'Active' && b.status === 'Active') return 1 + return Number(b.proposal.timeCreated) - Number(a.proposal.timeCreated) + }) + .slice(0, 5) + .map(({ proposal, status }) => ({ + id: `${proposal.chainId}-${proposal.id}`, + title: proposal.title?.trim() || `Proposal #${proposal.proposalNumber}`, + dao: proposal.dao.name, + amount: compactVotes(proposal.voteCount || 0), + status, + summary: + cleanSentence(proposal.description) || + `Proposal #${proposal.proposalNumber} in ${proposal.dao.name}.`, + href: `/dao/${chainIdToSlug(proposal.chainId)}/${proposal.dao.tokenAddress}/vote/${proposal.proposalId}`, + category: + PUBLIC_DEFAULT_CHAINS.find((chain) => chain.id === proposal.chainId)?.name || + 'DAO proposal', + })) +} + +const buildShowcaseResponse = async (): Promise => { + const chainData = await Promise.all( + PUBLIC_DEFAULT_CHAINS.map((chain) => fetchShowcaseDataForChain(chain.id)) + ) + + const coins = chainData.flatMap((item) => item.coins) + const drops = chainData.flatMap((item) => item.drops) + const proposals = chainData.flatMap((item) => item.proposals) + + return { + coining: await buildCoiningHighlights(coins), + drops: buildDropHighlights(drops), + proposals: buildProposalHighlights(proposals), + } +} + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + const { maxAge, swr } = CACHE_TIMES.EXPLORE + + try { + const payload = await buildShowcaseResponse() + + res.setHeader( + 'Cache-Control', + `public, s-maxage=${maxAge}, stale-while-revalidate=${swr}` + ) + + return res.status(200).json({ + coining: payload.coining.length ? payload.coining : fallbackCoiningHighlights, + drops: payload.drops.length ? payload.drops : fallbackDropHighlights, + proposals: payload.proposals.length + ? payload.proposals + : fallbackProposalHighlights, + }) + } catch (error) { + console.error('About showcase error:', error) + + res.setHeader( + 'Cache-Control', + `public, s-maxage=${maxAge}, stale-while-revalidate=${swr}` + ) + + return res.status(200).json({ + coining: fallbackCoiningHighlights, + drops: fallbackDropHighlights, + proposals: fallbackProposalHighlights, + }) + } +} + +export default withCors()( + withRateLimit({ + maxRequests: 60, + windowSeconds: 60, + keyPrefix: 'about:showcase', + })(handler) +) diff --git a/apps/web/src/pages/api/about/snapshot.ts b/apps/web/src/pages/api/about/snapshot.ts new file mode 100644 index 000000000..5a6822093 --- /dev/null +++ b/apps/web/src/pages/api/about/snapshot.ts @@ -0,0 +1,266 @@ +import { CACHE_TIMES } from '@buildeross/constants/cacheTimes' +import { PUBLIC_DEFAULT_CHAINS } from '@buildeross/constants/chains' +import { PUBLIC_SUBGRAPH_URL } from '@buildeross/constants/subgraph' +import { gql, GraphQLClient } from 'graphql-request' +import type { NextApiRequest, NextApiResponse } from 'next' +import { withCors } from 'src/utils/api/cors' +import { withRateLimit } from 'src/utils/api/rateLimit' +import { formatEther } from 'viem' + +import type { AboutSnapshotResponse } from '../../../modules/about/types' + +type SnapshotDao = { + id: string + totalAuctionSales: string + proposalCount: number + ownerCount: number + totalSupply: number + currentAuction?: { + id: string + endTime: string + settled: boolean + } | null +} + +type SnapshotQueryResponse = { + daos: SnapshotDao[] +} + +type SnapshotOwner = { + owner: string +} + +type SnapshotOwnersQueryResponse = { + daoTokenOwners: SnapshotOwner[] +} + +const ABOUT_SNAPSHOT_QUERY = gql` + query AboutSnapshot($first: Int!, $where: DAO_filter) { + daos(first: $first, where: $where, orderBy: totalAuctionSales, orderDirection: desc) { + id + totalAuctionSales + proposalCount + ownerCount + totalSupply + currentAuction { + id + endTime + settled + } + } + } +` + +const ABOUT_SNAPSHOT_OWNERS_QUERY = gql` + query AboutSnapshotOwners($first: Int!, $skip: Int!) { + daoTokenOwners(first: $first, skip: $skip) { + owner + } + } +` + +const compactNumber = (value: number) => + new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: value >= 1000 ? 1 : 0, + }).format(value) + +const compactEth = (value: number) => + `ETH ${new Intl.NumberFormat('en-US', { + notation: 'compact', + maximumFractionDigits: 1, + }).format(value)}` + +const OWNER_PAGE_SIZE = 1000 + +const fetchChainSnapshot = async (subgraphUrl: string): Promise => { + const client = new GraphQLClient(subgraphUrl, { + headers: { 'Content-Type': 'application/json' }, + }) + + const data = await client.request(ABOUT_SNAPSHOT_QUERY, { + first: 1000, + where: { + totalSupply_gt: 0, + }, + }) + + return data.daos ?? [] +} + +// TODO: This full scan of daoTokenOwners via paginated queries runs on every cache miss, +// causing unbounded latency on the public API. Move this logic to a background job/cron that +// periodically precomputes unique owner sets, then have the handler read that cached result. +// Not done now because it requires a separate background process + persistence layer. +const fetchUniqueOwnersForChain = async (subgraphUrl: string): Promise> => { + const client = new GraphQLClient(subgraphUrl, { + headers: { 'Content-Type': 'application/json' }, + }) + + const owners = new Set() + let skip = 0 + + while (true) { + const data = await client.request( + ABOUT_SNAPSHOT_OWNERS_QUERY, + { + first: OWNER_PAGE_SIZE, + skip, + } + ) + + const page = data.daoTokenOwners ?? [] + + for (const item of page) { + if (item.owner) { + owners.add(item.owner.toLowerCase()) + } + } + + if (page.length < OWNER_PAGE_SIZE) { + break + } + + skip += OWNER_PAGE_SIZE + } + + return owners +} + +const buildSnapshotResponse = async (): Promise => { + const now = Math.floor(Date.now() / 1000) + const chainSnapshots = await Promise.all( + PUBLIC_DEFAULT_CHAINS.map(async (chain) => { + const subgraphUrl = PUBLIC_SUBGRAPH_URL.get(chain.id) + if (!subgraphUrl) { + return { daos: [], uniqueOwners: null as Set | null } + } + + const [daos, uniqueOwners] = await Promise.all([ + fetchChainSnapshot(subgraphUrl), + fetchUniqueOwnersForChain(subgraphUrl).catch((error) => { + console.error( + `About snapshot unique owner fetch failed for chain ${chain.id}:`, + error + ) + return null + }), + ]) + + return { daos, uniqueOwners } + }) + ) + + const daos = chainSnapshots.flatMap((snapshot) => snapshot.daos) + + const totalDaos = daos.length + const totalAuctionSales = daos.reduce((total, dao) => { + const sales = Number(formatEther(BigInt(dao.totalAuctionSales || '0'))) + return total + sales + }, 0) + const activeAuctions = daos.filter( + (dao) => + dao.currentAuction && + !dao.currentAuction.settled && + Number(dao.currentAuction.endTime) > now + ).length + const totalProposals = daos.reduce( + (total, dao) => total + Number(dao.proposalCount || 0), + 0 + ) + const uniqueOwners = new Set() + + for (const snapshot of chainSnapshots) { + if (!snapshot.uniqueOwners) continue + + for (const owner of snapshot.uniqueOwners) { + uniqueOwners.add(owner) + } + } + + const allOwnerScansSucceeded = chainSnapshots.every((snapshot) => snapshot.uniqueOwners) + const fallbackTotalMembers = daos.reduce( + (total, dao) => total + Number(dao.ownerCount || 0), + 0 + ) + + const totalMembers = allOwnerScansSucceeded ? uniqueOwners.size : fallbackTotalMembers + const totalTokens = daos.reduce((total, dao) => total + Number(dao.totalSupply || 0), 0) + + return { + stats: [ + { + id: 'daos', + label: 'DAOs launched', + value: compactNumber(totalDaos), + detail: 'Live DAO count indexed across Builder-supported public networks.', + icon: '🚀', + }, + { + id: 'treasury', + label: 'Auction value raised', + value: compactEth(totalAuctionSales), + detail: + 'Cumulative native-token auction sales flowing into community treasuries.', + icon: '💰', + }, + { + id: 'auctions', + label: 'Active auctions', + value: compactNumber(activeAuctions), + detail: 'Current live auctions still accepting bids across the ecosystem.', + icon: '⏰', + }, + { + id: 'proposals', + label: 'Governance proposals', + value: compactNumber(totalProposals), + detail: 'Total proposals created across DAOs using Builder governance.', + icon: '📜', + }, + { + id: 'members', + label: 'Members holding tokens', + value: compactNumber(totalMembers), + detail: 'Distinct DAO token holders participating across Builder communities.', + icon: '👥', + }, + { + id: 'tokens', + label: 'Tokens auctioned', + value: compactNumber(totalTokens), + detail: 'Member tokens minted and distributed through recurring auctions.', + icon: '🔥', + }, + ], + } +} + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== 'GET') { + return res.status(405).json({ error: 'Method not allowed' }) + } + + try { + const payload = await buildSnapshotResponse() + const { maxAge, swr } = CACHE_TIMES.EXPLORE + + res.setHeader( + 'Cache-Control', + `public, s-maxage=${maxAge}, stale-while-revalidate=${swr}` + ) + + return res.status(200).json(payload) + } catch (error) { + console.error('About snapshot error:', error) + return res.status(500).json({ error: 'Failed to load about snapshot stats' }) + } +} + +export default withCors()( + withRateLimit({ + maxRequests: 60, + windowSeconds: 60, + keyPrefix: 'about:snapshot', + })(handler) +) diff --git a/apps/web/src/pages/api/ai/generateTxSummary.ts b/apps/web/src/pages/api/ai/generateTxSummary.ts index 76e3c09d0..3f9504d1e 100644 --- a/apps/web/src/pages/api/ai/generateTxSummary.ts +++ b/apps/web/src/pages/api/ai/generateTxSummary.ts @@ -1,4 +1,3 @@ -import { gateway } from '@ai-sdk/gateway' import { CACHE_TIMES } from '@buildeross/constants/cacheTimes' import { PUBLIC_ALL_CHAINS } from '@buildeross/constants/chains' import type { @@ -14,13 +13,9 @@ import type { DecodedEscrowData } from '@buildeross/utils/escrow' import { formatBpsValue, formatTokenValue } from '@buildeross/utils/formatArgs' import { walletSnippet } from '@buildeross/utils/helpers' import * as Sentry from '@sentry/nextjs' -import { generateText } from 'ai' import { NextApiRequest, NextApiResponse } from 'next' -import { getRedisConnection } from 'src/services/redisConnection' +import { AI_MODEL, generateCachedAiText } from 'src/utils/api/ai/summaries' import { withRateLimit } from 'src/utils/api/rateLimit' -import { keccak256, toHex } from 'viem' - -const AI_MODEL = process.env.AI_MODEL || 'openai/gpt-4-turbo' type RequestBody = { chainId: CHAIN_ID @@ -34,14 +29,6 @@ type RequestBody = { bundleContext?: ProposalTransactionBundleContext } -const safeStringify = (v: unknown) => - JSON.stringify(v, (_k, val) => (typeof val === 'bigint' ? val.toString() : val)) - -const getCacheKey = (data: RequestBody, model: string) => { - const hash = keccak256(toHex(`${safeStringify(data)}:${model}`)) - return `ai:txSummary:${hash}` -} - /** * Recursively traverses transaction arguments and formats * token-related fields or BPS percentage fields. @@ -318,27 +305,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const prompt = generatePrompt(requestData) const model = AI_MODEL - const redisConnection = getRedisConnection() - const cacheKey = getCacheKey(requestData, model) - - // Check cache first - let cachedText = await redisConnection?.get(cacheKey) - - if (cachedText) { - return res.status(200).json({ text: cachedText }) - } - - // Generate new text if not in cache - const result = await generateText({ - model: gateway(model), + const text = await generateCachedAiText({ + namespace: 'ai:txSummary', + data: requestData, prompt, - abortSignal: AbortSignal.timeout(30000), + model, }) - // Cache the generated text for 30 days - await redisConnection?.setex(cacheKey, 60 * 60 * 24 * 30, result.text) - - res.status(200).json({ text: result.text }) + res.status(200).json({ text }) } catch (error) { console.error(`Error generating transaction summary:`, error) diff --git a/apps/web/src/pages/api/migrated.ts b/apps/web/src/pages/api/migrated.ts index 3a67fa899..df2589036 100644 --- a/apps/web/src/pages/api/migrated.ts +++ b/apps/web/src/pages/api/migrated.ts @@ -14,7 +14,8 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const data = await Promise.all( L2_CHAINS.map((chainId) => { - const deployer = L2_MIGRATION_DEPLOYER[chainId] + const deployer = + L2_MIGRATION_DEPLOYER[chainId as keyof typeof L2_MIGRATION_DEPLOYER] if (deployer === zeroAddress) return [] return readContract(serverConfig, { diff --git a/apps/web/src/pages/dao/[network]/[token]/index.tsx b/apps/web/src/pages/dao/[network]/[token]/index.tsx index 9ee149ea7..36346da41 100644 --- a/apps/web/src/pages/dao/[network]/[token]/index.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/index.tsx @@ -43,6 +43,9 @@ const DaoPage: NextPageWithLayout = ({ chainId, collectionAddress const { address: signerAddress } = useAccount() const { addresses } = useDaoStore() const chain = useChainStore((x) => x.chain) + const chainIdKey = chain.id as keyof typeof MERKLE_RESERVE_MINTER + const merkleMinter = MERKLE_RESERVE_MINTER[chainIdKey] + const redeemMinter = ERC721_REDEEM_MINTER[chainIdKey] const auctionContractParams = { abi: auctionAbi, @@ -56,26 +59,40 @@ const DaoPage: NextPageWithLayout = ({ chainId, collectionAddress chainId: chain.id, } + const contracts = [ + { ...auctionContractParams, functionName: 'owner' as const }, + { ...tokenContractParams, functionName: 'remainingTokensInReserve' as const }, + ...(merkleMinter + ? ([ + { + ...tokenContractParams, + functionName: 'minter' as const, + args: [merkleMinter] as const, + }, + ] as const) + : []), + ...(redeemMinter + ? ([ + { + ...tokenContractParams, + functionName: 'minter' as const, + args: [redeemMinter] as const, + }, + ] as const) + : []), + ] + const { data: contractData } = useReadContracts({ allowFailure: false, - contracts: [ - { ...auctionContractParams, functionName: 'owner' }, - { ...tokenContractParams, functionName: 'remainingTokensInReserve' }, - { - ...tokenContractParams, - functionName: 'minter', - args: [MERKLE_RESERVE_MINTER[chain.id]], - }, - { - ...tokenContractParams, - functionName: 'minter', - args: [ERC721_REDEEM_MINTER[chain.id]], - }, - ] as const, + contracts, }) - const [owner, remainingTokensInReserve, isMerkleReserveMinter, isERC721RedeemMinter] = - unpackOptionalArray(contractData, 4) + const [owner, remainingTokensInReserve, ...minterStatus] = (unpackOptionalArray( + contractData, + contracts.length + ) ?? []) as [unknown, bigint | undefined, ...unknown[]] + const isMerkleReserveMinter = merkleMinter ? !!minterStatus[0] : false + const isERC721RedeemMinter = redeemMinter ? !!minterStatus[merkleMinter ? 1 : 0] : false // Separate read for signer minter status const { data: isSignerMinter } = useReadContract({ @@ -112,13 +129,13 @@ const DaoPage: NextPageWithLayout = ({ chainId, collectionAddress const handleMinterEnabled = React.useCallback( async (minterAddress: AddressType) => { // Navigate to appropriate tab when minter is enabled - if (minterAddress === MERKLE_RESERVE_MINTER[chain.id]) { + if (merkleMinter && minterAddress === merkleMinter) { await openTab('merkle-reserve') - } else if (minterAddress === ERC721_REDEEM_MINTER[chain.id]) { + } else if (redeemMinter && minterAddress === redeemMinter) { await openTab('erc721-redeem') } }, - [chain.id, openTab] + [merkleMinter, redeemMinter, openTab] ) const openTokenPage = React.useCallback( diff --git a/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx b/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx index 00b27bd06..9f9fe04c9 100644 --- a/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx @@ -51,6 +51,7 @@ import { notFoundWrap } from 'src/styles/404.css' import * as styles from 'src/styles/create.css' import { getAddress, isAddress, isAddressEqual } from 'viem' import { useAccount, useReadContract } from 'wagmi' +import { useShallow } from 'zustand/shallow' const createSelectOption = (type: TransactionType) => ({ value: type, @@ -149,23 +150,41 @@ const CreateProposalPage: NextPageWithLayout = () => { const addresses = useDaoStore((x) => x.addresses) const { auction, token } = addresses const chain = useChainStore((x) => x.chain) - const transactionType = useProposalStore((x) => x.transactionType) - const setTransactionType = useProposalStore((x) => x.setTransactionType) - const resetTransactionType = useProposalStore((x) => x.resetTransactionType) - const transactions = useProposalStore((x) => x.transactions) - const title = useProposalStore((x) => x.title) - const summary = useProposalStore((x) => x.summary) - const representedAddress = useProposalStore((x) => x.representedAddress) - const discussionUrl = useProposalStore((x) => x.discussionUrl) - const representedAddressEnabled = useProposalStore((x) => x.representedAddressEnabled) - const setTitle = useProposalStore((x) => x.setTitle) - const setSummary = useProposalStore((x) => x.setSummary) - const setRepresentedAddress = useProposalStore((x) => x.setRepresentedAddress) - const setDiscussionUrl = useProposalStore((x) => x.setDiscussionUrl) - const setRepresentedAddressEnabled = useProposalStore( - (x) => x.setRepresentedAddressEnabled + const { + transactionType, + setTransactionType, + resetTransactionType, + transactions, + title, + summary, + representedAddress, + discussionUrl, + representedAddressEnabled, + setTitle, + setSummary, + setRepresentedAddress, + setDiscussionUrl, + setRepresentedAddressEnabled, + clearProposal, + } = useProposalStore( + useShallow((state) => ({ + transactionType: state.transactionType, + setTransactionType: state.setTransactionType, + resetTransactionType: state.resetTransactionType, + transactions: state.transactions, + title: state.title, + summary: state.summary, + representedAddress: state.representedAddress, + discussionUrl: state.discussionUrl, + representedAddressEnabled: state.representedAddressEnabled, + setTitle: state.setTitle, + setSummary: state.setSummary, + setRepresentedAddress: state.setRepresentedAddress, + setDiscussionUrl: state.setDiscussionUrl, + setRepresentedAddressEnabled: state.setRepresentedAddressEnabled, + clearProposal: state.clearProposal, + })) ) - const clearProposal = useProposalStore((x) => x.clearProposal) const initialStageFromQuery = query?.stage === 'transactions' ? 'transactions' : 'draft' const [createStage, setCreateStage] = React.useState<'draft' | 'transactions'>(() => diff --git a/apps/web/src/utils/api/ai/summaries.ts b/apps/web/src/utils/api/ai/summaries.ts new file mode 100644 index 000000000..c8c3fb785 --- /dev/null +++ b/apps/web/src/utils/api/ai/summaries.ts @@ -0,0 +1,115 @@ +import { gateway } from '@ai-sdk/gateway' +import { generateText } from 'ai' +import { getRedisConnection } from 'src/services/redisConnection' + +export const AI_MODEL = process.env.AI_MODEL || 'openai/gpt-4-turbo' +const DEFAULT_CACHE_TTL_SECONDS = 60 * 60 * 24 * 30 +const MAX_DAO_DESCRIPTION_LENGTH = 2200 + +const safeStringify = (value: unknown) => + JSON.stringify(value, (_key, item) => + typeof item === 'bigint' ? item.toString() : item + ) + +export const getAiCacheKey = ( + namespace: string, + data: unknown, + model: string = AI_MODEL +) => { + const payload = `${namespace}:${model}:${safeStringify(data)}` + let hash = 2166136261 + + for (let i = 0; i < payload.length; i += 1) { + hash ^= payload.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + + return `${namespace}:${(hash >>> 0).toString(16)}` +} + +export const generateCachedAiText = async ({ + namespace, + data, + prompt, + model = AI_MODEL, + ttlSeconds = DEFAULT_CACHE_TTL_SECONDS, +}: { + namespace: string + data: unknown + prompt: string + model?: string + ttlSeconds?: number +}) => { + const redisConnection = getRedisConnection() + const cacheKey = getAiCacheKey(namespace, data, model) + + if (!redisConnection) { + console.warn( + `[ai-cache] Redis unavailable for namespace=${namespace} key=${cacheKey}; skipping cache.` + ) + } + + const cachedText = redisConnection ? await redisConnection.get(cacheKey) : null + + if (cachedText) { + return cachedText + } + + const result = await generateText({ + model: gateway(model), + prompt, + abortSignal: AbortSignal.timeout(30000), + }) + + const text = result.text.trim().replace(/\s+/g, ' ') + + if (redisConnection) { + await redisConnection.setex(cacheKey, ttlSeconds, text) + } + + return text +} + +const trimDescription = (description: string, maxChars = MAX_DAO_DESCRIPTION_LENGTH) => + description.trim().replace(/\s+/g, ' ').slice(0, maxChars) + +const sanitizeDescription = (description: string) => + description.replace(/###|assistant\s*:|system\s*:|user\s*:/gi, '') + +const buildDaoDescriptionPrompt = ( + description: string +) => `You are writing concise directory copy for a DAO platform. +Summarize the DAO description below into 1-2 short sentences for a directory card. + +Writing Rules: +- Keep it factual, clear, and neutral. +- Do not mention the DAO name. +- Do not use hype, markdown, bullet points, or line breaks. +- Prefer concrete activity, purpose, or community focus over abstract framing. +- Maximum 2 short sentences. + +DAO description: +${trimDescription(description)} + +Final Instruction: +Respond with only the 1-2 sentence summary.` + +export const summarizeDaoDescription = async (description: string) => { + if (description.length > MAX_DAO_DESCRIPTION_LENGTH) { + console.warn( + `[ai-cache] DAO description exceeded ${MAX_DAO_DESCRIPTION_LENGTH} chars; truncating before summarization.` + ) + } + + const cleaned = sanitizeDescription(trimDescription(description)) + + if (!cleaned.trim()) { + throw new Error('DAO description is empty after trimming/sanitization') + } + + return generateCachedAiText({ + namespace: 'ai:daoDescription', + data: { description: cleaned }, + prompt: buildDaoDescriptionPrompt(cleaned), + }) +} diff --git a/packages/dao-ui/src/components/Gallery/Gallery.tsx b/packages/dao-ui/src/components/Gallery/Gallery.tsx index 2a5b515ca..f1bbaa36c 100644 --- a/packages/dao-ui/src/components/Gallery/Gallery.tsx +++ b/packages/dao-ui/src/components/Gallery/Gallery.tsx @@ -127,6 +127,7 @@ const MintWidgetModal: React.FC = ({ editionSize={drop.editionSize} maxPerAddress={parseInt(drop.maxSalePurchasePerAddress)} onMintSuccess={handleCloseModal} + unstyledContainer /> diff --git a/packages/feed-ui/src/Modals/MintModal.tsx b/packages/feed-ui/src/Modals/MintModal.tsx index d0b1eb67c..88d1d06a3 100644 --- a/packages/feed-ui/src/Modals/MintModal.tsx +++ b/packages/feed-ui/src/Modals/MintModal.tsx @@ -74,6 +74,7 @@ export const MintModal: React.FC = ({ editionSize={editionSize} maxPerAddress={maxPerAddress} onMintSuccess={onSuccessMint} + unstyledContainer /> diff --git a/packages/proposal-ui/src/components/ProposalStatus/ProposalStatus.helper.ts b/packages/proposal-ui/src/components/ProposalStatus/ProposalStatus.helper.ts index 4e39eca17..310591805 100644 --- a/packages/proposal-ui/src/components/ProposalStatus/ProposalStatus.helper.ts +++ b/packages/proposal-ui/src/components/ProposalStatus/ProposalStatus.helper.ts @@ -2,6 +2,55 @@ import { ProposalState } from '@buildeross/sdk/contract' import { fromSeconds } from '@buildeross/utils/helpers' import { theme } from '@buildeross/zord' +export type ProposalStateColorStyle = { + borderColor: string + color: string +} + +export const proposalStateColorStyles: Record = { + [ProposalState.Pending]: { + borderColor: theme.colors.warningDisabled, + color: theme.colors.warning, + }, + [ProposalState.Active]: { + borderColor: theme.colors.focusRing, + color: theme.colors.focusRing, + }, + [ProposalState.Canceled]: { + borderColor: theme.colors.background2, + color: theme.colors.text4, + }, + [ProposalState.Defeated]: { + borderColor: theme.colors.negativeDisabled, + color: theme.colors.negative, + }, + [ProposalState.Succeeded]: { + borderColor: theme.colors.positiveDisabled, + color: theme.colors.positive, + }, + [ProposalState.Queued]: { + borderColor: theme.colors.neutral, + color: theme.colors.secondary, + }, + [ProposalState.Expired]: { + borderColor: theme.colors.background2, + color: theme.colors.text4, + }, + [ProposalState.Executed]: { + borderColor: theme.colors.positiveDisabled, + color: theme.colors.positive, + }, + [ProposalState.Vetoed]: { + borderColor: theme.colors.background2, + color: theme.colors.text4, + }, +} + +export const getProposalStateColorStyle = ( + state: ProposalState +): ProposalStateColorStyle => + proposalStateColorStyles[state] || proposalStateColorStyles[ProposalState.Expired] + export function formatTime( timediff: number, affix: string, @@ -58,39 +107,5 @@ export function parseState(state: ProposalState) { } export function parseBgColor(state: ProposalState) { - switch (state) { - case ProposalState.Pending: - return { - borderColor: theme.colors.warningDisabled, - color: theme.colors.warning, - } - case ProposalState.Active: - return { - borderColor: '#0085FF', - color: '#0085FF', - } - case ProposalState.Succeeded: - return { - borderColor: theme.colors.positiveDisabled, - color: theme.colors.positive, - } - case ProposalState.Defeated: - return { - borderColor: theme.colors.negativeDisabled, - color: theme.colors.negative, - } - case ProposalState.Executed: - return { - borderColor: theme.colors.positiveDisabled, - color: theme.colors.positive, - } - case ProposalState.Queued: - return { - borderColor: theme.colors.neutral, - color: theme.colors.secondary, - } - case ProposalState.Expired: - default: - return { borderColor: theme.colors.background2, color: theme.colors.text4 } - } + return getProposalStateColorStyle(state) } diff --git a/packages/proposal-ui/src/components/ProposalStatus/index.ts b/packages/proposal-ui/src/components/ProposalStatus/index.ts index 6e7bb6159..b91603830 100644 --- a/packages/proposal-ui/src/components/ProposalStatus/index.ts +++ b/packages/proposal-ui/src/components/ProposalStatus/index.ts @@ -1 +1,2 @@ export * from './ProposalStatus' +export * from './ProposalStatus.helper' diff --git a/packages/proposal-ui/src/index.ts b/packages/proposal-ui/src/index.ts index ef98a54ff..698cd7956 100644 --- a/packages/proposal-ui/src/index.ts +++ b/packages/proposal-ui/src/index.ts @@ -1,2 +1,3 @@ export * from './components' +export * from './components/ProposalStatus/ProposalStatus.helper' export * from './constants' diff --git a/packages/sdk/src/subgraph/sdk.generated.ts b/packages/sdk/src/subgraph/sdk.generated.ts index 386ab26d5..dd4deb4df 100644 --- a/packages/sdk/src/subgraph/sdk.generated.ts +++ b/packages/sdk/src/subgraph/sdk.generated.ts @@ -30,6 +30,14 @@ export type Scalars = { Timestamp: { input: any; output: any } } +/** Indicates whether the current, partially filled bucket should be included in the response. Defaults to `exclude` */ +export enum Aggregation_Current { + /** Exclude the current, partially filled bucket from the response */ + Exclude = 'exclude', + /** Include the current, partially filled bucket in the response */ + Include = 'include', +} + export enum Aggregation_Interval { Day = 'day', Hour = 'hour', @@ -1283,10 +1291,8 @@ export type ClankerToken_Filter = { extensionsSupply_not?: InputMaybe extensionsSupply_not_in?: InputMaybe> extensions_contains?: InputMaybe> - extensions_contains_nocase?: InputMaybe> extensions_not?: InputMaybe> extensions_not_contains?: InputMaybe> - extensions_not_contains_nocase?: InputMaybe> holders_?: InputMaybe id?: InputMaybe id_gt?: InputMaybe @@ -4436,10 +4442,8 @@ export type Proposal_Filter = { snapshotBlockNumber_not_in?: InputMaybe> targets?: InputMaybe> targets_contains?: InputMaybe> - targets_contains_nocase?: InputMaybe> targets_not?: InputMaybe> targets_not_contains?: InputMaybe> - targets_not_contains_nocase?: InputMaybe> timeCreated?: InputMaybe timeCreated_gt?: InputMaybe timeCreated_gte?: InputMaybe @@ -4481,10 +4485,8 @@ export type Proposal_Filter = { updates_?: InputMaybe values?: InputMaybe> values_contains?: InputMaybe> - values_contains_nocase?: InputMaybe> values_not?: InputMaybe> values_not_contains?: InputMaybe> - values_not_contains_nocase?: InputMaybe> vetoTransactionHash?: InputMaybe vetoTransactionHash_contains?: InputMaybe vetoTransactionHash_gt?: InputMaybe diff --git a/packages/ui/src/DropMintWidget/DropMintWidget.css.ts b/packages/ui/src/DropMintWidget/DropMintWidget.css.ts index de6c1767f..b17bcc65c 100644 --- a/packages/ui/src/DropMintWidget/DropMintWidget.css.ts +++ b/packages/ui/src/DropMintWidget/DropMintWidget.css.ts @@ -5,7 +5,17 @@ export const widgetContainer = style([ atoms({ p: 'x6', borderRadius: 'phat', - backgroundColor: 'background2', + backgroundColor: 'background1', + borderColor: 'border', + borderStyle: 'solid', + borderWidth: 'thin', + }), +]) + +export const widgetContainerUnstyled = style([ + atoms({ + p: 'x0', + borderRadius: 'curved', }), ]) @@ -20,8 +30,8 @@ export const mintInputContainer = style([ export const mintInput = style({ fontSize: '1.125rem', fontWeight: 600, - border: '2px solid transparent', - backgroundColor: 'transparent', + border: `2px solid ${vars.color.background1}`, + backgroundColor: vars.color.background2, textAlign: 'center', width: '100%', flex: 1, @@ -34,6 +44,10 @@ export const mintInput = style({ borderColor: vars.color.border, backgroundColor: vars.color.background1, }, + '&:focus-visible': { + outline: `2px solid ${vars.color.focusRing}`, + outlineOffset: '2px', + }, '&::-webkit-outer-spin-button, &::-webkit-inner-spin-button': { WebkitAppearance: 'none', margin: 0, @@ -46,7 +60,7 @@ export const mintInput = style({ export const quantityInputWrapper = style([ atoms({ - backgroundColor: 'background1', + backgroundColor: 'transparent', borderRadius: 'curved', p: 'x2', }), @@ -78,7 +92,10 @@ export const quantityButton = style({ selectors: { '&:disabled': { cursor: 'not-allowed', - opacity: 0.4, + opacity: 1, + color: vars.color.text3, + borderColor: vars.color.border, + backgroundColor: vars.color.background1, }, }, }) diff --git a/packages/ui/src/DropMintWidget/DropMintWidget.tsx b/packages/ui/src/DropMintWidget/DropMintWidget.tsx index bdcc197ea..f13b7f64b 100644 --- a/packages/ui/src/DropMintWidget/DropMintWidget.tsx +++ b/packages/ui/src/DropMintWidget/DropMintWidget.tsx @@ -17,6 +17,7 @@ import { quantityInputWrapper, successMessage, widgetContainer, + widgetContainerUnstyled, } from './DropMintWidget.css' export interface DropMintWidgetProps { @@ -32,6 +33,7 @@ export interface DropMintWidgetProps { editionSize?: string maxPerAddress?: number onMintSuccess?: (txHash: string) => void + unstyledContainer?: boolean } export const DropMintWidget = ({ @@ -47,6 +49,7 @@ export const DropMintWidget = ({ // editionSize, maxPerAddress, onMintSuccess, + unstyledContainer = false, }: DropMintWidgetProps) => { const [quantity, setQuantity] = useState(1) const [comment, setComment] = useState('') @@ -119,7 +122,10 @@ export const DropMintWidget = ({ } return ( - + {/* Price */} @@ -170,15 +176,18 @@ export const DropMintWidget = ({ Quantity - + {quantity > 1 ? ( + + ) : ( + + )}