From aa0a02659d19975cede64174677b1983a91a7282 Mon Sep 17 00:00:00 2001 From: xSatori <99294685+xSatori@users.noreply.github.com> Date: Mon, 13 Apr 2026 17:07:26 -0400 Subject: [PATCH 01/44] Refine about page and build support --- apps/web/package.json | 1 + apps/web/public/builderlogo.png | Bin 0 -> 4241 bytes apps/web/public/images/about-divider.svg | 48 + apps/web/src/modules/about/AboutPage.css.ts | 1318 +++++++++++++++++ apps/web/src/modules/about/AboutPage.tsx | 152 ++ .../about/components/AboutFinalCta.tsx | 56 + .../modules/about/components/AboutHero.tsx | 120 ++ .../modules/about/components/CoiningCard.tsx | 59 + .../components/CoiningHighlightsSection.tsx | 34 + .../src/modules/about/components/DaoCard.tsx | 73 + .../components/DroposalHighlightsSection.tsx | 101 ++ .../components/EcosystemActivitySection.tsx | 40 + .../about/components/EcosystemStatGrid.tsx | 33 + .../about/components/FeaturedDaoSection.tsx | 82 + .../about/components/HowItWorksSection.tsx | 35 + .../components/ProposalHighlightsSection.tsx | 25 + .../modules/about/components/SectionIntro.tsx | 26 + .../about/components/WhatIsBuilderSection.tsx | 43 + .../components/WhatYouCanBuildSection.tsx | 44 + .../about/components/WhyBuilderSection.tsx | 39 + apps/web/src/modules/about/data.ts | 288 ++++ apps/web/src/modules/about/index.ts | 1 + apps/web/src/modules/about/types.ts | 111 ++ apps/web/src/modules/about/useAboutDaoTabs.ts | 47 + .../web/src/modules/about/useAboutShowcase.ts | 44 + .../modules/about/useAboutSnapshotStats.ts | 44 + apps/web/src/modules/about/utils.ts | 16 + apps/web/src/modules/dashboard/Dashboard.tsx | 4 +- .../modules/dashboard/SingleDaoSelector.tsx | 12 +- apps/web/src/pages/about.tsx | 72 +- apps/web/src/pages/api/about/dao-tabs.ts | 563 +++++++ apps/web/src/pages/api/about/showcase.ts | 396 +++++ apps/web/src/pages/api/about/snapshot.ts | 264 ++++ .../web/src/pages/api/ai/generateTxSummary.ts | 38 +- apps/web/src/pages/api/migrated.ts | 3 +- .../src/pages/dao/[network]/[token]/index.tsx | 11 +- .../dao/[network]/[token]/proposal/create.tsx | 34 +- apps/web/src/styles/about.css.ts | 17 - apps/web/src/utils/api/ai/summaries.ts | 84 ++ pnpm-lock.yaml | 15 +- turbo.json | 2 + 41 files changed, 4241 insertions(+), 154 deletions(-) create mode 100644 apps/web/public/builderlogo.png create mode 100644 apps/web/public/images/about-divider.svg create mode 100644 apps/web/src/modules/about/AboutPage.css.ts create mode 100644 apps/web/src/modules/about/AboutPage.tsx create mode 100644 apps/web/src/modules/about/components/AboutFinalCta.tsx create mode 100644 apps/web/src/modules/about/components/AboutHero.tsx create mode 100644 apps/web/src/modules/about/components/CoiningCard.tsx create mode 100644 apps/web/src/modules/about/components/CoiningHighlightsSection.tsx create mode 100644 apps/web/src/modules/about/components/DaoCard.tsx create mode 100644 apps/web/src/modules/about/components/DroposalHighlightsSection.tsx create mode 100644 apps/web/src/modules/about/components/EcosystemActivitySection.tsx create mode 100644 apps/web/src/modules/about/components/EcosystemStatGrid.tsx create mode 100644 apps/web/src/modules/about/components/FeaturedDaoSection.tsx create mode 100644 apps/web/src/modules/about/components/HowItWorksSection.tsx create mode 100644 apps/web/src/modules/about/components/ProposalHighlightsSection.tsx create mode 100644 apps/web/src/modules/about/components/SectionIntro.tsx create mode 100644 apps/web/src/modules/about/components/WhatIsBuilderSection.tsx create mode 100644 apps/web/src/modules/about/components/WhatYouCanBuildSection.tsx create mode 100644 apps/web/src/modules/about/components/WhyBuilderSection.tsx create mode 100644 apps/web/src/modules/about/data.ts create mode 100644 apps/web/src/modules/about/index.ts create mode 100644 apps/web/src/modules/about/types.ts create mode 100644 apps/web/src/modules/about/useAboutDaoTabs.ts create mode 100644 apps/web/src/modules/about/useAboutShowcase.ts create mode 100644 apps/web/src/modules/about/useAboutSnapshotStats.ts create mode 100644 apps/web/src/modules/about/utils.ts create mode 100644 apps/web/src/pages/api/about/dao-tabs.ts create mode 100644 apps/web/src/pages/api/about/showcase.ts create mode 100644 apps/web/src/pages/api/about/snapshot.ts delete mode 100644 apps/web/src/styles/about.css.ts create mode 100644 apps/web/src/utils/api/ai/summaries.ts diff --git a/apps/web/package.json b/apps/web/package.json index 1dea5e859..dc0869166 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", diff --git a/apps/web/public/builderlogo.png b/apps/web/public/builderlogo.png new file mode 100644 index 0000000000000000000000000000000000000000..1b06d6e5cadee1306168527b712f0e807bd9d707 GIT binary patch literal 4241 zcmchaYh2RVy2t-&lTBl~D`&Jabr)WCI@xF`hUnyEikVu8mrQWdG*i4QiJ~<&2pW95jqloW4x$!$j7O7jYMzkvb)q7sV7&VI4qofrGeIWN}b#riy-XFbpQ{MPUL zz|-A%-`>M}0RY$sb2)zn08}$~_HXv=-kGD4+g|M~Uq-w5#R0%q2R{!LfK;dp0Dt=d zcK)2#ok9Vp7YABA*Cj^66FmA?dhVbF0itnOG$Oiim($;mS!8NEJvz{?@@2aNpfi>8 z5FG93^57vj=KeuWoMYzw@6Yd_F#;L>WEgV*Vw^*+Wi}y1!j%;_k5=srzIeQkuyM15 zQ4-={P&!yVT8e2@@G7Gm;A9$#4FGLNhRXDJvg;yv?gE~;n5qJvAZ-=E^uSL3+Fv>W zK<52p0N~`fAGmw0Xb zf>Wa;BT?wrBpc48hj;1%dR`5*0mBFVs#V0 zTV4T5BhICFkJ_(St>KW+`Mu`%zV1}&8jrcg+OuwT22Ib^Kc0)g%gooMfkD+40Oq{u zTGwe70>5mguWr&^3xdEjYdFhyUuR)e-W_+Wb{LP>sdYbKLbkP3=e687SKJWGZyL|Bns3^@6Vw-Se`lLFPw(3cFH)F+`VMvP8-ZF*CK&x+*E-8izl!$@MXrr|%NR zIX-gFgFkHzWxzgv`cRE=`|~T7-=Uqac0`Tkt?&Fjx1QLdTHUFn0K1P$aXd4l$GhZqU^chbKCZ8o8cz#9SKWEDxju7=w?3niVC4A z$7FCzc&~8VQ}V{bq_{Vjq+}X)*KPbnw|6-iVJu2Av_+p7LtNBc*!r^bIq$~$>+G15 z$d*C_q%x-RdIMhETfmAQrTRuJvLf&ig)nOa_rB(mCWCT|x7Z=IT@xpFu0G6VZo5g2 z;7oKFc&`30GoOxWZwsCK7ZeVgx7Vo2C&xG|NYlR8Ts3){4k6A1)Zt_*8C+m3oJv}W zm*k_3JnVkAtEae)EAQV3Kv!?fWF^b{`sn!x1%s2qPd&t2=AA^nk6z$!5ic;5_xZU$BrfKZZ-+Ps>zR}6f2p0E%nBqWOG zsHq;7N714W9m_5aK{2SATh)JTJd(0Z3}Ahg78DxA4mmWFVblF4wfu z`9&PEzeR%CX{fEV{s0PnX=d%3WXanmO&yAD+07={S#ekk%CEvIw~?{Q&HTSJt?21< zE6Id(Zm_+-?AG0;cZ0aA#G7Y5*4*3v(H8@8fsFdfP6;OhfA>n=^D$%rWr-q)UrtKV zQP)E441&FJ1Dfs?7_f*F81x;wKpC*6(wyTtm#~1+$->%r)Pl9Rak-2o|Difo`Jxm( zoEXQbAf@(CHu4rKUG7iBX{-M46!1or*Jmw_rtZ(g79I4XohWs)1P~skYn}0F;*50L zi>Ig{!j{eUI#6G=r?W*@1PV(y<{VLt2Ol+=Ehy910J?etht#IN9dCR=m8al&O1m&0 z=wbIv`P1IuG*a!=*+S*dOe0~K-a(xw=<=Poow$jflvv0tIc%WL`HVqOC0F zqN#+gr~fjPH}eA9k)|c`%7q~9@aADg`|s)>%*4jjtL}1UHLFf7ek=?9i~g;Di|Xz& z{Wj?3ZzHiIEz|lruWBX@;ElErfc)2pQ%Sq7Ayb6_r!*3 zQtw-!fb|E2Bbgaxx^&*@S#hc{qbonhBrH_wm5`MqfhnCt))I!5PC}J$FXTIrPiqN->f}uZuKPYPQRDybXN%;D|!qfu$#sj zRvjz1maZfs>WO2@RDNx3ZjM0k*rNmn4^_MxE5xoi>fh;aqQ|VYe{g)%6n&>Lsj-#H zHOrH}wd!A7c$Q~1(U6O&C-Q&27GrJe0wK?HG7S2g9K`Q`bB`*?hR58cfE$W@OCa0? z#r$%^=G=O$Onv(i*UTeyCr-!*s%POP<5K?Fro735^a6v6XRh}zt>$YikH6Ir`I1j+ zcR?5u$<_Y3XWLsBA_G!S%#xSGh8w?LER)780(c8I8d3(+vN67jKT7wPicy{+L^!)#&FAqtyn}-B!n$WJ*EY<7s+JA@`-81 zY+$IvNZRI=#HtjN1S!=oZ3=T`$eI3!uaT5zM^CqA{rqwa@>8~Iv=({A(+!`_-CAkc zbTwvB!?$G?6eRDl&`~z#z_2Lrh+xiVZ@ zHo4Vx@lcMYrT^<;6<;Eu^(6dE0%_V1ZgoxODRhP71~f8CNrY`ujL0h?7xV6EdK-JjWE6cP6=EI0T?3nc{X2K{r;JjmIk`-!`CPFH!oS@CVs z?M4RwM-G!$51H~V(|;rO_F+A7y^y~14P>|56=O&t_U>nxO#XCp+#s`)Lw?}2)G#Z4 zgqa_h2VKG!EA}$I;rVqaMABNr3Ic1Z7+JT1fNOfGU^u$zJ9o;C`GMs2VCo;kZD8Yb z&&Co%N+t_d5U!+2f_HZ z&-;xE3bN8sxSkzbV!M9cQrBp3Rn%Fp^Uc096k`j4@g#Eq+ny8+&40P&*F#$tDtfYf zF38(AQYukc@St;9u;8|(ZsNt~*mwUjB`XWUg&$z_VeESzD_o^{LcFI_XM52MR)^=D ztwC9bckCz`XOmUS!H9o$>lzs-_sf&}kw6KI!%~KakroQX!02Wk$?{Bz6;-cbr$z|s zl^}ort*<(Hsq5eOrI{Xz1QFm`OS)wQE=;nL9Xw$?*-DJ4GE#>&p05nUKX;GhjJ}y? zcE$4APt0~1c%0<8{nAkhQamA>&|x((#$zeqq^SU|&QWUfJO8fZ+Um>Ozek!69%qMG zm0KCS3>!8sRacaTCJX4>1B8y(d(HbanJ)+&dzWES@L3VkRG)Hl!nXb~tI3{Fc*G=q z_PgFmVq*0!dL3C~xWZIvo`AO=qb8P3+q~v-jt*xzl~e3O;X_7Pz0N%2&spFtF2`7Sad`{u*|{~urzXtM^Cn7_ZEi#%zpr{#b|z|&shM$abaCB~ zvzXmpm~`f!A&>25NejwLDeZ)qLX4^}9_M8QY7?PhBye|ZzS$$k4{piG_+b^O5e zuD@kABdDo?@{muBHUEUf;TI~~RC69s0yb5FHs-Ov7!4K0t^=S$#)m5Im>24W!*=^A zL(efsbpYrwZAH+-;Nz_PuW{*w`F0SScPuiWs$SgL0xaz>8Z{t*82IZjON7`LY>RM{ zxZ<}!QX^q#)MxB^dYhd%6a?H2s@fq5+dv^m?#Qm2JWCTdd&A*rF9&4PhSj?7hi@yEX~_wIc7z?Nx@pEuUdfrUfmlHgi_I~WCgkLw{c zbFaQjXk6~e<3+pCjYNyt#6!PkghbW1B0u;EYiFY#gnuqnBQ-Bh1=z6tzm(DU+iG!_ XM>mTe>m1&pcmU?)e!kK%@TdO*pqKjI literal 0 HcmV?d00001 diff --git a/apps/web/public/images/about-divider.svg b/apps/web/public/images/about-divider.svg new file mode 100644 index 000000000..00941d7ae --- /dev/null +++ b/apps/web/public/images/about-divider.svg @@ -0,0 +1,48 @@ + + image + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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..ea7481e9b --- /dev/null +++ b/apps/web/src/modules/about/AboutPage.css.ts @@ -0,0 +1,1318 @@ +import { keyframes, style } from '@vanilla-extract/css' + +const focusRing = { + outline: '3px solid rgba(37, 99, 235, 0.28)', + outlineOffset: '3px', +} + +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: '#FFFFFF', + '@media': { + 'screen and (min-width: 768px)': { + padding: '64px 24px 120px', + }, + }, +}) + +export const container = style({ + maxWidth: '1180px', + margin: '0 auto', +}) + +export const centeredImageWrap = style({ + display: 'flex', + justifyContent: 'center', +}) + +export const centeredImage = style({ + width: '280px', + maxWidth: '100%', + height: 'auto', + display: 'block', +}) + +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', +}) + +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: '6px 12px', + background: '#FFFFFF', + border: '1px solid #E6E3D7', + fontSize: '12px', + lineHeight: '16px', + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: '#2563EB', + fontWeight: 700, +}) + +export const sectionTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '32px', + lineHeight: 1.05, + whiteSpace: 'nowrap', + color: '#111111', + '@media': { + 'screen and (min-width: 768px)': { + fontSize: '44px', + }, + }, +}) + +export const sectionTitleOnly = style({ + marginBottom: '12px', +}) + +export const sectionCopy = style({ + fontSize: '17px', + lineHeight: 1.6, + color: '#5C5648', +}) + +export const introCopyNoWrap = style({ + fontSize: '17px', + lineHeight: 1.6, + color: '#5C5648', + 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', +}) + +export const heroTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '44px', + lineHeight: 0.98, + letterSpacing: '-0.03em', + color: '#111111', + maxWidth: '760px', + '@media': { + 'screen and (min-width: 768px)': { + fontSize: '60px', + }, + }, +}) + +export const heroText = style({ + maxWidth: '640px', + fontSize: '18px', + lineHeight: 1.65, + color: '#4C463B', +}) + +export const heroHighlightList = style({ + display: 'grid', + gap: '10px', + margin: 0, + padding: 0, + listStyle: 'none', + marginTop: '-4px', +}) + +export const heroHighlight = style({ + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '15px', + lineHeight: 1.5, + color: '#232018', +}) + +export const heroHighlightDot = style({ + width: '10px', + height: '10px', + borderRadius: '999px', + background: '#2563EB', + flexShrink: 0, +}) + +export const primaryButton = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '42px', + padding: '10px 16px', + borderRadius: '999px', + border: '1px solid #2563EB', + color: '#FFFFFF', + background: '#2563EB', + fontSize: '14px', + fontWeight: 700, + textDecoration: 'none', + transition: 'background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease', + selectors: { + '&:hover': { + borderColor: '#2563EB', + boxShadow: 'inset 0 0 0 1px #2563EB', + backgroundColor: '#EFF6FF', + color: '#1E3A8A', + }, + '&:focus-visible': focusRing, + }, +}) + +export const heroActions = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', + alignItems: 'stretch', + '@media': { + 'screen and (min-width: 640px)': { + flexDirection: 'row', + alignItems: 'center', + }, + }, +}) + +export const secondaryButton = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '42px', + padding: '10px 16px', + borderRadius: '999px', + border: '1px solid #DBEAFE', + color: '#1E3A8A', + background: '#FFFFFF', + fontSize: '14px', + fontWeight: 700, + textDecoration: 'none', + transition: 'background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease', + selectors: { + '&:hover': { + cursor: 'pointer', + backgroundColor: '#EFF6FF', + borderColor: '#93C5FD', + }, + '&:focus-visible': focusRing, + }, +}) + +export const heroPanel = style({ + position: 'relative', + overflow: 'hidden', + borderRadius: '28px', + border: '1px solid #E6E1D4', + background: '#F8FBFF', + padding: '22px', + minHeight: '420px', + boxShadow: '0 10px 24px rgba(17, 17, 17, 0.04)', +}) + +export const heroPanelGlow = style({ + position: 'absolute', + inset: 0, + background: + 'radial-gradient(circle at 20% 20%, rgba(147, 197, 253, 0.18), transparent 24%), radial-gradient(circle at 80% 10%, rgba(191, 219, 254, 0.18), transparent 18%)', + pointerEvents: 'none', +}) + +export const montageGrid = style({ + position: 'relative', + zIndex: 1, + display: 'grid', + gap: '14px', + gridTemplateColumns: '1.05fr 0.95fr', + gridTemplateAreas: '"primary side" "footer footer"', + '@media': { + 'screen and (max-width: 540px)': { + gridTemplateColumns: '1fr', + gridTemplateAreas: '"primary" "side" "footer"', + }, + }, +}) + +export const montageCard = style({ + borderRadius: '20px', + border: '1px solid rgba(17, 17, 17, 0.08)', + background: 'rgba(255, 255, 255, 0.92)', + padding: '18px', + boxShadow: '0 6px 16px rgba(17, 17, 17, 0.04)', +}) + +export const montagePrimary = style({ + gridArea: 'primary', + minHeight: '150px', +}) + +export const montageSide = style({ + gridArea: 'side', + minHeight: '220px', +}) + +export const montageSecondary = style({ + gridArea: 'secondary', + minHeight: '150px', +}) + +export const montageFooter = style({ + gridArea: 'footer', + minHeight: '180px', + padding: '10px', +}) + +export const logoMarquee = style({ + width: '100%', + height: '100%', + minHeight: '160px', + overflow: 'hidden', + borderRadius: '16px', + background: + 'linear-gradient(135deg, rgba(239, 246, 255, 0.9) 0%, rgba(255, 255, 255, 0.9) 100%)', +}) + +export const logoMarqueeTrack = style({ + position: 'relative', + overflow: 'hidden', + width: '100%', + height: '100%', + selectors: { + '&::before': { + content: '', + position: 'absolute', + inset: 0, + background: + 'linear-gradient(90deg, rgba(248, 251, 255, 1) 0%, rgba(248, 251, 255, 0) 10%, rgba(248, 251, 255, 0) 90%, rgba(248, 251, 255, 1) 100%)', + zIndex: 2, + pointerEvents: 'none', + }, + }, +}) + +export const logoMarqueeInner = style({ + display: 'flex', + alignItems: 'center', + gap: '18px', + width: 'max-content', + minWidth: '100%', + height: '100%', + padding: '22px 0', + animation: `${marqueeScroll} 28s linear infinite`, +}) + +export const logoMarqueeItem = style({ + width: '86px', + height: '86px', + borderRadius: '22px', + overflow: 'hidden', + background: '#FFFFFF', + border: '1px solid rgba(37, 99, 235, 0.08)', + boxShadow: '0 10px 22px rgba(17, 17, 17, 0.06)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, +}) + +export const logoMarqueeImage = style({ + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', +}) + +export const montageLabel = style({ + fontSize: '12px', + letterSpacing: '0.08em', + textTransform: 'uppercase', + color: '#726A5B', + fontWeight: 700, +}) + +export const montageValue = style({ + marginTop: '10px', + fontFamily: 'ptRoot, sans-serif', + fontSize: '28px', + lineHeight: 1, + whiteSpace: 'pre-line', + color: '#111111', +}) + +export const montageBody = style({ + marginTop: '10px', + fontSize: '14px', + lineHeight: 1.55, + color: '#5C5648', +}) + +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: '#EFF6FF', +}) + +export const daoMiniAvatar = style({ + width: '40px', + height: '40px', + borderRadius: '14px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontWeight: 700, + fontSize: '14px', + color: '#111111', +}) + +export const heroFooterStat = style({ + display: 'flex', + flexDirection: 'column', + gap: '6px', +}) + +export const heroFooterValue = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '24px', + color: '#111111', +}) + +export const statGrid = style({ + display: 'grid', + 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: '20px', + border: '1px solid #E6E1D4', + background: '#FFFFFF', + padding: '22px', + boxShadow: '0 8px 18px rgba(17, 17, 17, 0.04)', + transition: 'transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease', + selectors: { + '&:hover': { + transform: 'translateY(-1px)', + borderColor: '#D7D0C0', + boxShadow: '0 10px 22px rgba(17, 17, 17, 0.05)', + }, + }, +}) + +export const statAccent = style({ + position: 'absolute', + top: '18px', + right: '18px', + width: '54px', + height: '54px', + borderRadius: '18px', + opacity: 0.9, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: '28px', + lineHeight: 1, +}) + +export const statLabel = style({ + position: 'relative', + zIndex: 1, + fontSize: '13px', + fontWeight: 700, + letterSpacing: '0.06em', + textTransform: 'uppercase', + color: '#6F685A', +}) + +export const statValue = style({ + position: 'relative', + zIndex: 1, + marginTop: '18px', + fontFamily: 'ptRoot, sans-serif', + fontSize: '42px', + lineHeight: 1, + color: '#111111', +}) + +export const statDetail = style({ + position: 'relative', + zIndex: 1, + marginTop: '18px', + maxWidth: '28ch', + fontSize: '14px', + lineHeight: 1.55, + color: '#5A5347', +}) + +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: '17px', + lineHeight: 1.6, + color: '#5C5648', + margin: 0, +}) + +export const tabs = style({ + display: 'flex', + flexWrap: 'wrap', + gap: '10px', +}) + +export const tabButton = style({ + minHeight: '42px', + borderRadius: '999px', + border: '1px solid #DBEAFE', + background: '#FFFFFF', + padding: '10px 16px', + fontSize: '14px', + fontWeight: 700, + color: '#1E3A8A', + transition: 'background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease', + selectors: { + '&:hover': { + cursor: 'pointer', + borderColor: '#93C5FD', + backgroundColor: '#EFF6FF', + }, + '&:focus-visible': focusRing, + }, +}) + +export const activeTabButton = style({ + background: '#2563EB', + borderColor: '#2563EB', + color: '#FFFFFF', + selectors: { + '&:hover': { + borderColor: '#2563EB', + boxShadow: 'inset 0 0 0 1px #2563EB', + backgroundColor: '#EFF6FF', + color: '#1E3A8A', + }, + }, +}) + +export const daoGrid = 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(4, minmax(0, 1fr))', + }, + }, +}) + +export const daoCard = style({ + display: 'flex', + flexDirection: 'column', + gap: '18px', + minHeight: '360px', + borderRadius: '22px', + border: '1px solid #E6E1D4', + background: '#FFFFFF', + padding: '18px', + boxShadow: '0 8px 18px rgba(17, 17, 17, 0.04)', + transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease', + selectors: { + '&:hover': { + transform: 'translateY(-1px)', + borderColor: '#D7D0C0', + boxShadow: '0 10px 22px rgba(17, 17, 17, 0.05)', + }, + }, +}) + +export const daoTop = style({ + display: 'flex', + justifyContent: 'space-between', + gap: '12px', + alignItems: 'flex-start', +}) + +export const daoChainBadge = style({ + width: '34px', + height: '34px', + borderRadius: '12px', + border: '1px solid #DCE7F8', + background: '#FFFFFF', + boxShadow: '0 6px 14px rgba(17, 17, 17, 0.05)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + overflow: 'hidden', +}) + +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: '18px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontFamily: 'ptRoot, sans-serif', + fontSize: '18px', + fontWeight: 700, + flexShrink: 0, + overflow: 'hidden', +}) + +export const daoAvatarImage = style({ + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', +}) + +export const daoName = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '20px', + lineHeight: 1.05, + color: '#111111', +}) + +export const daoDescription = style({ + fontSize: '14px', + lineHeight: 1.6, + color: '#5B5649', +}) + +export const badge = style({ + display: 'inline-flex', + width: 'fit-content', + borderRadius: '999px', + border: '1px solid rgba(17, 17, 17, 0.1)', + padding: '6px 10px', + fontSize: '12px', + fontWeight: 700, +}) + +export const daoSignal = style({ + display: 'grid', + gap: '6px', + marginTop: 'auto', + padding: '14px 16px', + borderRadius: '16px', + background: '#EFF6FF', +}) + +export const daoSignalLabel = style({ + fontSize: '12px', + textTransform: 'uppercase', + letterSpacing: '0.06em', + color: '#2563EB', + fontWeight: 700, +}) + +export const daoSignalValue = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '18px', + lineHeight: 1.2, + whiteSpace: 'nowrap', + color: '#111111', +}) + +export const cardLink = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '10px', + color: '#1D4ED8', + fontSize: '14px', + fontWeight: 700, + textDecoration: 'none', + selectors: { + '&:focus-visible': focusRing, + }, +}) + +export const scrollRow = style({ + display: 'grid', + gap: '16px', + gridAutoFlow: 'column', + gridAutoColumns: 'minmax(280px, 1fr)', + overflowX: 'auto', + paddingBottom: '8px', + scrollSnapType: 'x mandatory', + selectors: { + '&::-webkit-scrollbar': { + height: '10px', + }, + '&::-webkit-scrollbar-thumb': { + background: '#D6D1C4', + borderRadius: '999px', + }, + }, + '@media': { + 'screen and (min-width: 1080px)': { + gridAutoColumns: 'minmax(280px, 320px)', + }, + }, +}) + +export const coiningCard = style({ + display: 'flex', + flexDirection: 'column', + gap: '16px', + minHeight: '360px', + scrollSnapAlign: 'start', + borderRadius: '22px', + border: '1px solid #E6E1D4', + background: '#FFFFFF', + padding: '18px', + boxShadow: '0 8px 18px rgba(17, 17, 17, 0.04)', + transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease', + selectors: { + '&:hover': { + transform: 'translateY(-1px)', + borderColor: '#D7D0C0', + boxShadow: '0 10px 22px rgba(17, 17, 17, 0.05)', + }, + }, +}) + +export const coiningPreview = style({ + position: 'relative', + minHeight: '220px', + borderRadius: '22px', + overflow: 'hidden', + padding: '18px', + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', +}) + +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: 'rgba(255, 255, 255, 0.76)', + padding: '6px 10px', + fontSize: '12px', + fontWeight: 700, + color: '#111111', +}) + +export const coiningNetworkBadge = style({ + width: '34px', + height: '34px', + borderRadius: '12px', + border: '1px solid rgba(17, 17, 17, 0.08)', + background: 'rgba(255, 255, 255, 0.88)', + boxShadow: '0 6px 14px rgba(17, 17, 17, 0.05)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + overflow: 'hidden', +}) + +export const coiningPreviewTitle = style({ + maxWidth: '10ch', + fontFamily: 'ptRoot, sans-serif', + fontSize: '32px', + lineHeight: 0.95, + color: '#111111', +}) + +export const coiningMeta = style({ + display: 'grid', + gap: '6px', + flex: 1, +}) + +export const coiningTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '22px', + lineHeight: 1.05, + color: '#111111', +}) + +export const mutedText = style({ + fontSize: '14px', + lineHeight: 1.6, + color: '#5D564A', +}) + +export const amountPill = style({ + display: 'inline-flex', + width: 'fit-content', + borderRadius: '999px', + padding: '7px 12px', + background: '#2563EB', + color: '#FFFFFF', + fontSize: '13px', + fontWeight: 700, +}) + +export const droposalList = style({ + display: 'grid', + gap: '14px', +}) + +export const droposalCard = style({ + display: 'grid', + gap: '14px', + borderRadius: '20px', + border: '1px solid #E6E1D4', + background: '#FFFFFF', + padding: '18px', + boxShadow: '0 8px 18px rgba(17, 17, 17, 0.04)', + transition: 'transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease', + selectors: { + '&:hover': { + transform: 'translateY(-1px)', + borderColor: '#D7D0C0', + boxShadow: '0 10px 22px rgba(17, 17, 17, 0.05)', + }, + }, + '@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', + padding: '7px 12px', + fontSize: '12px', + fontWeight: 700, +}) + +export const droposalTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '24px', + lineHeight: 1.05, + color: '#111111', +}) + +export const droposalSummary = style({ + fontSize: '15px', + lineHeight: 1.65, + color: '#5A5348', + 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: '22px', + border: '1px solid #E6E1D4', + background: '#FFFFFF', + padding: '22px', + boxShadow: '0 8px 18px rgba(17, 17, 17, 0.04)', +}) + +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: '18px', + alignItems: 'center', + justifyContent: 'center', + background: '#2563EB', + color: '#FFFFFF', + fontFamily: 'ptRoot, sans-serif', + fontSize: '18px', + flexShrink: 0, +}) + +export const stepTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '28px', + lineHeight: 1.02, + color: '#111111', +}) + +export const stepBody = style({ + marginTop: '14px', + fontSize: '15px', + lineHeight: 1.65, + color: '#585244', +}) + +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 compactValueGrid = style({ + display: 'grid', + width: '100%', + maxWidth: '100%', + alignSelf: 'stretch', + gap: '16px', + gridTemplateColumns: 'repeat(1, minmax(0, 1fr))', + '@media': { + 'screen and (min-width: 768px)': { + gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', + }, + }, +}) + +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: 768px)': { + gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', + }, + }, +}) + +export const valueCard = style({ + minHeight: '150px', + borderRadius: '20px', + border: '1px solid #E6E1D4', + background: '#FFFFFF', + padding: '20px', + boxShadow: '0 8px 18px rgba(17, 17, 17, 0.04)', +}) + +export const compactValueCard = style({ + minHeight: '160px', + borderRadius: '20px', + border: '1px solid #E6E1D4', + background: '#FFFFFF', + padding: '20px', + boxShadow: '0 8px 18px rgba(17, 17, 17, 0.04)', + display: 'flex', + flexDirection: 'column', + gap: '12px', +}) + +export const valueTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '24px', + lineHeight: 1.05, + color: '#111111', +}) + +export const compactValueTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '18px', + lineHeight: 1.2, + color: '#111111', + 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: '20px', + border: '1px solid #E6E1D4', + background: '#FFFFFF', + 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: '#F4F1E8', + color: '#575041', +}) + +export const activityTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '22px', + lineHeight: 1.08, + color: '#111111', +}) + +export const finalCta = style({ + position: 'relative', + overflow: 'hidden', + borderRadius: '26px', + border: '1px solid #BFDBFE', + background: 'linear-gradient(135deg, #1D4ED8 0%, #2563EB 58%, #3B82F6 100%)', + color: '#FFFFFF', + padding: '30px 22px', + boxShadow: '0 16px 30px rgba(17, 17, 17, 0.14)', + '@media': { + 'screen and (min-width: 768px)': { + padding: '40px 34px', + }, + }, +}) + +export const finalCtaGlow = style({ + position: 'absolute', + inset: 0, + background: + 'radial-gradient(circle at 10% 10%, rgba(191, 219, 254, 0.3), transparent 24%), radial-gradient(circle at 92% 20%, rgba(255, 255, 255, 0.16), transparent 22%)', + pointerEvents: 'none', +}) + +export const finalCtaContent = style({ + position: 'relative', + zIndex: 1, + display: 'grid', + gap: '24px', + alignItems: 'center', + '@media': { + 'screen and (min-width: 980px)': { + gridTemplateColumns: 'minmax(0, 1fr) auto', + gap: '32px', + }, + }, +}) + +export const finalCtaTitle = style({ + fontFamily: 'ptRoot, sans-serif', + fontSize: '36px', + lineHeight: 0.98, + whiteSpace: 'nowrap', + '@media': { + 'screen and (min-width: 768px)': { + fontSize: '52px', + }, + }, +}) + +export const finalChecklist = style({ + marginTop: '12px', + marginBottom: 0, + padding: 0, + listStyle: 'none', + display: 'grid', + gap: '10px', +}) + +export const finalChecklistItem = style({ + display: 'flex', + alignItems: 'center', + gap: '10px', + fontSize: '17px', + lineHeight: 1.6, + color: 'rgba(255, 255, 255, 0.78)', +}) + +export const finalChecklistMarker = style({ + minWidth: '24px', + color: '#FFFFFF', + 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 lightButton = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '42px', + padding: '10px 16px', + borderRadius: '999px', + border: '1px solid #2563EB', + background: '#2563EB', + color: '#FFFFFF', + fontSize: '14px', + fontWeight: 700, + textDecoration: 'none', + transition: 'background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease', + selectors: { + '&:hover': { + borderColor: '#2563EB', + boxShadow: 'inset 0 0 0 1px #2563EB', + backgroundColor: '#EFF6FF', + color: '#1E3A8A', + }, + '&:focus-visible': focusRing, + }, +}) + +export const finalPrimaryButton = style([ + lightButton, + { + border: '1px solid rgba(255, 255, 255, 0.72)', + boxShadow: + '0 0 0 1px rgba(255, 255, 255, 0.24), inset 0 0 0 1px rgba(255, 255, 255, 0.08)', + selectors: { + '&:hover': { + borderColor: '#FFFFFF', + boxShadow: '0 0 0 1px rgba(255, 255, 255, 0.42), inset 0 0 0 1px #2563EB', + }, + }, + }, +]) + +export const ghostButton = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minHeight: '42px', + padding: '10px 16px', + borderRadius: '999px', + background: '#FFFFFF', + color: '#1E3A8A', + fontSize: '14px', + fontWeight: 700, + textDecoration: 'none', + border: '1px solid #DBEAFE', + transition: 'background-color 0.18s ease, color 0.18s ease, border-color 0.18s ease', + selectors: { + '&:hover': { + cursor: 'pointer', + backgroundColor: '#EFF6FF', + borderColor: '#93C5FD', + }, + '&:focus-visible': focusRing, + }, +}) + +export const subLink = style({ + color: 'rgba(255, 255, 255, 0.86)', + fontSize: '14px', + fontWeight: 700, + textDecoration: 'none', + selectors: { + '&:hover': { + color: '#FFFFFF', + }, + '&:focus-visible': focusRing, + }, +}) + +export const heroSubLink = style({ + color: '#2563EB', + fontSize: '14px', + fontWeight: 700, + textDecoration: 'none', + selectors: { + '&:hover': { + color: '#1D4ED8', + }, + '&:focus-visible': focusRing, + }, +}) + +export const helperText = style({ + fontSize: '14px', + lineHeight: 1.55, + color: '#5B6477', +}) diff --git a/apps/web/src/modules/about/AboutPage.tsx b/apps/web/src/modules/about/AboutPage.tsx new file mode 100644 index 000000000..d7484ce80 --- /dev/null +++ b/apps/web/src/modules/about/AboutPage.tsx @@ -0,0 +1,152 @@ +import { useChainStore } from '@buildeross/stores' +import { Box } from '@buildeross/zord' +import React from 'react' + +import { + centeredImage, + centeredImageWrap, + container, + 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..5b5887226 --- /dev/null +++ b/apps/web/src/modules/about/components/AboutFinalCta.tsx @@ -0,0 +1,56 @@ +import { Box, Text } from '@buildeross/zord' +import Link from 'next/link' +import React from 'react' + +import { + finalActions, + finalChecklist, + finalChecklistItem, + finalChecklistMarker, + finalCta, + finalCtaContent, + finalCtaGlow, + finalCtaTitle, + finalPrimaryButton, + ghostButton, + subLink, +} from '../AboutPage.css' + +export const AboutFinalCta: React.FC = () => { + return ( + + + + + Start your DAO today + + + 1. + Upload your art + + + 2. + Set your parameters + + + 3. + Launch your first auction + + + + + + + Launch your DAO + + + Explore the ecosystem + + + Read the docs + + + + + ) +} 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..b98adf5b2 --- /dev/null +++ b/apps/web/src/modules/about/components/AboutHero.tsx @@ -0,0 +1,120 @@ +import { Box, Text } from '@buildeross/zord' +import Link from 'next/link' +import React from 'react' + +import { + hero, + heroActions, + heroCopy, + heroHighlight, + heroHighlightDot, + heroHighlightList, + heroPanel, + heroPanelGlow, + heroText, + heroTitle, + heroVennImage, + heroVennWrap, + logoMarquee, + logoMarqueeImage, + logoMarqueeInner, + logoMarqueeItem, + logoMarqueeTrack, + montageBody, + montageCard, + montageFooter, + montageGrid, + montagePrimary, + montageSide, + montageValue, + primaryButton, + secondaryButton, +} 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 a DAO + + + + Explore existing DAOs + + + + + + + + + {`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..1b10355e5 --- /dev/null +++ b/apps/web/src/modules/about/components/CoiningCard.tsx @@ -0,0 +1,59 @@ +import { Box, Text } from '@buildeross/zord' +import Link from 'next/link' +import React from 'react' + +import { + cardLink, + 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 ? ( + + + + ) : null} + + {item.previewLabel} + + + + {item.title} + + By {item.creator} for {item.dao} + + + + + View post + + + ) +} 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..aa5fa7782 --- /dev/null +++ b/apps/web/src/modules/about/components/DaoCard.tsx @@ -0,0 +1,73 @@ +import { FallbackImage } from '@buildeross/ui' +import { Box, Flex, Text } from '@buildeross/zord' +import Link from 'next/link' +import React from 'react' + +import { + cardLink, + daoAvatar, + daoAvatarImage, + daoCard, + daoChainBadge, + daoChainBadgeImage, + daoDescription, + daoIdentity, + daoName, + daoSignal, + daoSignalLabel, + daoSignalValue, + daoTop, +} from '../AboutPage.css' +import { AboutDao } from '../types' + +type DaoCardProps = { + dao: AboutDao +} + +export const DaoCard: React.FC = ({ dao }) => { + return ( + + + + + {dao.imageUrl ? ( + + ) : ( + dao.initials + )} + + + {dao.name} + {dao.description} + + + {dao.chainIcon ? ( + + + + ) : null} + + + + {dao.signalLabel} + {dao.signalValue} + + + + View DAO + + + ) +} 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..a2964686a --- /dev/null +++ b/apps/web/src/modules/about/components/DroposalHighlightsSection.tsx @@ -0,0 +1,101 @@ +import { Box, Text } from '@buildeross/zord' +import Link from 'next/link' +import React from 'react' + +import { + badge, + cardLink, + 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 statusStyles: Record = { + Active: { background: '#FFF2BF', color: '#6A5300' }, + Passed: { background: '#DDF7E7', color: '#0F5B37' }, + Funded: { background: '#DCE6FF', color: '#1D3F84' }, + Executed: { background: '#ECE7FF', color: '#44348F' }, + Trending: { background: '#FFE1D7', color: '#7D2E0B' }, + Live: { background: '#DDF7E7', color: '#0F5B37' }, + Recent: { background: '#DCE6FF', color: '#1D3F84' }, +} + +type DroposalHighlightsSectionProps = { + items?: DroposalHighlight[] + eyebrowText?: string + title?: string + copy?: string + linkLabel?: string +} + +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', +}) => { + const highlights = items?.length ? items : dropHighlights + + return ( + + + + + {highlights.map((proposal) => ( + + + + + {proposal.dao} + + + {proposal.status} + + {getChainLogoSrc(proposal.category) ? ( + + + + ) : ( + {proposal.category} + )} + + + {proposal.title} + + + {proposal.summary} + + + + + {proposal.amount} + + {linkLabel} + + + + ))} + + + ) +} 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..0aab29b56 --- /dev/null +++ b/apps/web/src/modules/about/components/EcosystemStatGrid.tsx @@ -0,0 +1,33 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + statAccent, + statCard, + statDetail, + statGrid, + statLabel, + statValue, +} from '../AboutPage.css' +import { AboutStat } from '../types' + +type EcosystemStatGridProps = { + stats: AboutStat[] +} + +export const EcosystemStatGrid: React.FC = ({ stats }) => { + 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..41a0996f5 --- /dev/null +++ b/apps/web/src/modules/about/components/FeaturedDaoSection.tsx @@ -0,0 +1,82 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + activeTabButton, + daoGrid, + sectionInlineCopy, + sectionInlineRow, + sectionTitle, + sectionTitleOnly, + 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) => ( + + ))} + + + + + + + ) +} 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..5aadf3d9b --- /dev/null +++ b/apps/web/src/modules/about/components/SectionIntro.tsx @@ -0,0 +1,26 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { sectionCopy, sectionHeader, sectionTitle } from '../AboutPage.css' + +type SectionIntroProps = { + eyebrowText: string + title: string + copy?: string + copyClassName?: string +} + +export const SectionIntro: React.FC = ({ + title, + copy, + copyClassName, +}) => { + return ( + + + {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..83ac218c8 --- /dev/null +++ b/apps/web/src/modules/about/components/WhatYouCanBuildSection.tsx @@ -0,0 +1,44 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + 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.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..6fdf7cb38 --- /dev/null +++ b/apps/web/src/modules/about/components/WhyBuilderSection.tsx @@ -0,0 +1,39 @@ +import { Box, Text } from '@buildeross/zord' +import React from 'react' + +import { + compactValueCard, + compactValueGrid, + compactValueTitle, + mutedText, + section, +} from '../AboutPage.css' +import { builderValueProps } from '../data' +import { SectionIntro } from './SectionIntro' + +export const WhyBuilderSection: React.FC = () => { + return ( + + + + + {builderValueProps.map((item) => ( + + + {item.title} + + {item.body ? ( + + {item.body} + + ) : null} + + ))} + + + ) +} diff --git a/apps/web/src/modules/about/data.ts b/apps/web/src/modules/about/data.ts new file mode 100644 index 000000000..8fc39b32e --- /dev/null +++ b/apps/web/src/modules/about/data.ts @@ -0,0 +1,288 @@ +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', + accent: '#7D53FF', + 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', + accent: '#FF8A3D', + 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', + accent: '#0DBA80', + 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', + accent: '#296BFF', + 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: 'Funded', + 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: 'Passed', + 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: 'Trending', + 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.png', + }, + { 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..c03a73a5b --- /dev/null +++ b/apps/web/src/modules/about/types.ts @@ -0,0 +1,111 @@ +export type AboutStat = { + id: string + label: string + value: string + detail: string + accent: 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 + surface: string + textAccent: 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 + accent: string + surface: string + previewLabel: string +} + +export type DroposalStatus = + | 'Active' + | 'Passed' + | 'Funded' + | '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..7b3ad8559 --- /dev/null +++ b/apps/web/src/modules/about/useAboutDaoTabs.ts @@ -0,0 +1,47 @@ +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=${network}`, { + 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 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..06d28a026 --- /dev/null +++ b/apps/web/src/modules/about/useAboutSnapshotStats.ts @@ -0,0 +1,44 @@ +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() + 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 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..af9e443bf --- /dev/null +++ b/apps/web/src/modules/about/utils.ts @@ -0,0 +1,16 @@ +export const getChainLogoSrc = (chainName?: string | null) => { + const normalized = chainName?.trim().toLowerCase() + + switch (normalized) { + case 'base': + return '/chains/base.svg' + case 'ethereum': + return '/chains/ethereum.svg' + case 'optimism': + return '/chains/optimism.svg' + case 'zora': + return '/chains/zora.png' + default: + return null + } +} diff --git a/apps/web/src/modules/dashboard/Dashboard.tsx b/apps/web/src/modules/dashboard/Dashboard.tsx index 39fab8b6c..548747f6a 100644 --- a/apps/web/src/modules/dashboard/Dashboard.tsx +++ b/apps/web/src/modules/dashboard/Dashboard.tsx @@ -130,7 +130,9 @@ export const Dashboard: React.FC = () => { 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/pages/about.tsx b/apps/web/src/pages/about.tsx index 2445f14c8..bc2f09451 100644 --- a/apps/web/src/pages/about.tsx +++ b/apps/web/src/pages/about.tsx @@ -1,84 +1,16 @@ -import { useChainStore } from '@buildeross/stores' -import { ContractButton } from '@buildeross/ui/ContractButton' -import { Box, Stack } 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 { 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..80e81a5b5 --- /dev/null +++ b/apps/web/src/pages/api/about/dao-tabs.ts @@ -0,0 +1,563 @@ +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 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 getSurface = (index: number) => + ['#EFF6FF', '#DBEAFE', '#BFDBFE', '#E0F2FE'][index % 4] + +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, + surface: getSurface(index), + textAccent: '#1E3A8A', + 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 results = await Promise.all( + PUBLIC_DEFAULT_CHAINS.map(async (chain) => { + const daos = await fetchDaoCandidates(chain.id, options) + + return daos.map((dao) => ({ chainId: chain.id, dao })) + }) + ) + + return results.flat() +} + +const findFeaturedMatch = (targets: string[], candidates: CrossChainDaoCandidate[]) => + candidates.find((item) => isNameMatch(targets, item.dao.name)) + +const fetchFeaturedMatch = async (targets: string[]) => { + const results = await Promise.all( + PUBLIC_DEFAULT_CHAINS.flatMap((chain) => + targets.map(async (target) => { + const daos = await fetchDaoCandidates(chain.id, { + first: 25, + nameContains: target, + }) + + return daos.map((dao) => ({ chainId: chain.id, dao })) + }) + ) + ) + + return findFeaturedMatch(targets, results.flat()) +} + +const buildFeaturedDaos = async ( + resolveDescription: ReturnType +): Promise => { + const results = await Promise.all( + PUBLIC_DEFAULT_CHAINS.map(async (chain) => { + const daos = await fetchDaoCandidates(chain.id, { first: 800 }) + + return daos.map((dao) => ({ chainId: chain.id, dao })) + }) + ) + + const featuredCandidates = results.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', + surface: getSurface(index), + textAccent: '#1E3A8A', + 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 activeResults = await Promise.all( + PUBLIC_DEFAULT_CHAINS.map(async (chain) => { + const result = await exploreDaosRequest(chain.id, 12, 0, Auction_OrderBy.EndTime) + + return (result?.daos ?? []).map((dao) => ({ + chainId: chain.id, + dao, + })) + }) + ) + + const activeDaos = activeResults.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, index) => { + 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', + surface: getSurface(index), + textAccent: '#1E3A8A', + 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..9f9a4b952 --- /dev/null +++ b/apps/web/src/pages/api/about/showcase.ts @@ -0,0 +1,396 @@ +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 + 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 + queued + executed + canceled + vetoed + dao { + name + tokenAddress + } + } + } +` + +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 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 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 'Funded' + if (Number(proposal.voteEnd) > now) return 'Active' + return 'Passed' +} + +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', + accent: '#2563EB', + 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) => Number(b.totalSalesAmount || '0') - Number(a.totalSalesAmount || '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' }) + } + + try { + const payload = await buildShowcaseResponse() + const { maxAge, swr } = CACHE_TIMES.EXPLORE + + 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) + + 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..ee270aaa9 --- /dev/null +++ b/apps/web/src/pages/api/about/snapshot.ts @@ -0,0 +1,264 @@ +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 ?? [] +} + +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 totalMembers = + uniqueOwners.size || + daos.reduce((total, dao) => total + Number(dao.ownerCount || 0), 0) + 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.', + accent: '#2563EB', + icon: '🚀', + }, + { + id: 'treasury', + label: 'Auction value raised', + value: compactEth(totalAuctionSales), + detail: + 'Cumulative native-token auction sales flowing into community treasuries.', + accent: '#2563EB', + icon: '💰', + }, + { + id: 'auctions', + label: 'Active auctions', + value: compactNumber(activeAuctions), + detail: 'Current live auctions still accepting bids across the ecosystem.', + accent: '#2563EB', + icon: '⏰', + }, + { + id: 'proposals', + label: 'Governance proposals', + value: compactNumber(totalProposals), + detail: 'Total proposals created across DAOs using Builder governance.', + accent: '#2563EB', + icon: '📜', + }, + { + id: 'members', + label: 'Members holding tokens', + value: compactNumber(totalMembers), + detail: 'Distinct DAO token holders participating across Builder communities.', + accent: '#2563EB', + icon: '👥', + }, + { + id: 'tokens', + label: 'Tokens auctioned', + value: compactNumber(totalTokens), + detail: 'Member tokens minted and distributed through recurring auctions.', + accent: '#2563EB', + 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 22e6739c9..b882c5674 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 { @@ -12,13 +11,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 @@ -30,14 +25,6 @@ type RequestBody = { escrowData?: DecodedEscrowData } -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. @@ -276,27 +263,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..d199b67c6 100644 --- a/apps/web/src/pages/dao/[network]/[token]/index.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/index.tsx @@ -43,6 +43,7 @@ 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 auctionContractParams = { abi: auctionAbi, @@ -64,12 +65,12 @@ const DaoPage: NextPageWithLayout = ({ chainId, collectionAddress { ...tokenContractParams, functionName: 'minter', - args: [MERKLE_RESERVE_MINTER[chain.id]], + args: [MERKLE_RESERVE_MINTER[chainIdKey]], }, { ...tokenContractParams, functionName: 'minter', - args: [ERC721_REDEEM_MINTER[chain.id]], + args: [ERC721_REDEEM_MINTER[chainIdKey]], }, ] as const, }) @@ -112,13 +113,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 (minterAddress === MERKLE_RESERVE_MINTER[chainIdKey]) { await openTab('merkle-reserve') - } else if (minterAddress === ERC721_REDEEM_MINTER[chain.id]) { + } else if (minterAddress === ERC721_REDEEM_MINTER[chainIdKey]) { await openTab('erc721-redeem') } }, - [chain.id, openTab] + [chainIdKey, 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 d24f62033..69d99e1ad 100644 --- a/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx +++ b/apps/web/src/pages/dao/[network]/[token]/proposal/create.tsx @@ -146,23 +146,23 @@ 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 clearProposal = useProposalStore((x) => x.clearProposal) + const { + transactionType, + setTransactionType, + resetTransactionType, + transactions, + title, + summary, + representedAddress, + discussionUrl, + representedAddressEnabled, + setTitle, + setSummary, + setRepresentedAddress, + setDiscussionUrl, + setRepresentedAddressEnabled, + clearProposal, + } = useProposalStore() const initialStageFromQuery = query?.stage === 'transactions' ? 'transactions' : 'draft' const [createStage, setCreateStage] = React.useState<'draft' | 'transactions'>(() => diff --git a/apps/web/src/styles/about.css.ts b/apps/web/src/styles/about.css.ts deleted file mode 100644 index d7caa55aa..000000000 --- a/apps/web/src/styles/about.css.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { globalStyle, style } from '@vanilla-extract/css' - -export const whyTextStyle = style({ - maxWidth: 720, -}) - -globalStyle(`${whyTextStyle} > *`, { - fontFamily: 'Arial Narrow, sans-serif!important', -}) - -export const whyCreateButton = style({ - selectors: { - '&:hover': { - cursor: 'pointer', - }, - }, -}) 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..e7b063e3a --- /dev/null +++ b/apps/web/src/utils/api/ai/summaries.ts @@ -0,0 +1,84 @@ +import { gateway } from '@ai-sdk/gateway' +import { generateText } from 'ai' +import { getRedisConnection } from 'src/services/redisConnection' +import { keccak256, toHex } from 'viem' + +export const AI_MODEL = process.env.AI_MODEL || 'openai/gpt-4-turbo' +const DEFAULT_CACHE_TTL_SECONDS = 60 * 60 * 24 * 30 + +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 hash = keccak256(toHex(`${safeStringify(data)}:${model}`)) + return `${namespace}:${hash}` +} + +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) + + const cachedText = await redisConnection?.get(cacheKey) + + if (cachedText) { + return cachedText + } + + const result = await generateText({ + model: gateway(model), + prompt, + abortSignal: AbortSignal.timeout(30000), + }) + + const text = result.text.trim().replace(/\s+/g, ' ') + + await redisConnection?.setex(cacheKey, ttlSeconds, text) + + return text +} + +const trimDescription = (description: string, maxChars = 2200) => + description.trim().replace(/\s+/g, ' ').slice(0, maxChars) + +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) => + generateCachedAiText({ + namespace: 'ai:daoDescription', + data: { description }, + prompt: buildDaoDescriptionPrompt(description), + }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad9d715f5..4a03ea9a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,7 +169,7 @@ importers: version: 2.2.10(@tanstack/react-query@5.90.3(react@19.2.1))(@types/react@19.2.2)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)(typescript@5.9.3)(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12))(wagmi@2.18.1(@tanstack/query-core@5.90.3)(@tanstack/react-query@5.90.3(react@19.2.1))(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(ioredis@5.8.1)(react@19.2.1)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.47.1(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12))(zod@4.1.12)) '@sentry/nextjs': specifier: ^9.22.0 - version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) + version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) '@smartinvoicexyz/types': specifier: ^0.1.27 version: 0.1.28(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.1))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.2)(react@19.2.1))(@types/react@19.2.2)(react@19.2.1))(@types/node@22.18.10)(@types/react@19.2.2)(bufferutil@4.0.9)(encoding@0.1.13)(framer-motion@12.23.24(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(graphql-ws@6.0.6(crossws@0.3.5)(graphql@16.11.0)(ws@8.18.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(graphql@16.11.0)(react-dom@19.2.1(react@19.2.1))(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.12) @@ -212,6 +212,9 @@ importers: framer-motion: specifier: ^12.12.1 version: 12.23.24(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + graphql-request: + specifier: ^7.1.2 + version: 7.3.0(graphql@16.11.0) ioredis: specifier: ^5.8.1 version: 5.8.1 @@ -1339,7 +1342,7 @@ importers: dependencies: '@sentry/nextjs': specifier: ^9.22.0 - version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) + version: 9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1) graphql: specifier: ^16.11.0 version: 16.11.0 @@ -15336,10 +15339,10 @@ snapshots: '@metamask/superstruct': 3.2.1 '@noble/hashes': 1.8.0 '@scure/base': 1.2.6 - '@types/debug': 4.1.12 + '@types/debug': 4.1.13 debug: 4.4.3(supports-color@8.1.1) pony-cause: 2.1.11 - semver: 7.7.3 + semver: 7.7.4 uuid: 9.0.1 transitivePeerDependencies: - supports-color @@ -16505,7 +16508,7 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1)': + '@sentry/nextjs@9.46.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.5.9(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.102.1)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.37.0 @@ -24667,7 +24670,7 @@ snapshots: rlp@2.2.7: dependencies: - bn.js: 5.2.2 + bn.js: 5.2.3 rollup@4.52.4: dependencies: diff --git a/turbo.json b/turbo.json index 03c1fecc0..16f0579fd 100644 --- a/turbo.json +++ b/turbo.json @@ -9,6 +9,8 @@ "ETHERSCAN_API_KEY", "AI_GATEWAY_API_KEY", "AI_MODEL", + "OPENAI_API_KEY", + "NEYNAR_API_KEY", "NEXT_PUBLIC_DISABLE_AI_SUMMARY", "NEXT_PUBLIC_DISABLE_TENDERLY_SIMULATION", "NEXT_PUBLIC_PINATA_GATEWAY", From d8a527095d85d5ce602fbc26e8dd57cd478891b2 Mon Sep 17 00:00:00 2001 From: xSatori <99294685+xSatori@users.noreply.github.com> Date: Mon, 13 Apr 2026 22:42:57 -0400 Subject: [PATCH 02/44] Add dark mode and refine dark theme surfaces --- .../layouts/CreateDaoLayout/Nav.styles.css.ts | 13 ++- apps/web/src/layouts/CreateDaoLayout/Nav.tsx | 5 +- .../layouts/DefaultLayout/Nav.styles.css.ts | 100 +++++++++++++++++- apps/web/src/layouts/DefaultLayout/Nav.tsx | 11 +- .../DefaultLayout/NavMenu/ChainMenu.tsx | 8 +- .../layouts/DefaultLayout/NavMenu/NavMenu.tsx | 2 + .../DefaultLayout/NavMenu/ProfileMenu.tsx | 7 +- .../DefaultLayout/NavMenu/ThemeToggle.tsx | 37 +++++++ .../modules/dashboard/UserProfileCard.css.ts | 13 ++- .../src/modules/explore/ExploreToolbar.tsx | 6 +- .../src/modules/explore/SearchInput.css.ts | 14 +-- apps/web/src/pages/_app.tsx | 13 ++- apps/web/src/pages/_document.tsx | 34 +++++- apps/web/src/pages/about.tsx | 4 +- apps/web/src/styles/globals.css | 21 +++- apps/web/src/theme/AppThemeProvider.tsx | 71 +++++++++++++ apps/web/src/theme/theme.ts | 9 ++ packages/zord/src/theme.css.ts | 2 +- 18 files changed, 334 insertions(+), 36 deletions(-) create mode 100644 apps/web/src/layouts/DefaultLayout/NavMenu/ThemeToggle.tsx create mode 100644 apps/web/src/theme/AppThemeProvider.tsx create mode 100644 apps/web/src/theme/theme.ts diff --git a/apps/web/src/layouts/CreateDaoLayout/Nav.styles.css.ts b/apps/web/src/layouts/CreateDaoLayout/Nav.styles.css.ts index 71d5cf372..05fb3c8a7 100644 --- a/apps/web/src/layouts/CreateDaoLayout/Nav.styles.css.ts +++ b/apps/web/src/layouts/CreateDaoLayout/Nav.styles.css.ts @@ -1,5 +1,5 @@ import * as z from '@buildeross/constants/layers' -import { atoms } from '@buildeross/zord' +import { atoms, color } from '@buildeross/zord' import { style } from '@vanilla-extract/css' export const NavContainer = style([ @@ -29,7 +29,7 @@ export const NavWrapper = style([ export const uploadNotificationWrapper = style({ '@media': { '(max-width: 768px)': { - background: '#fff', + background: color.background1, bottom: 0, paddingTop: 5, paddingBottom: 5, @@ -41,3 +41,12 @@ export const navLogo = style({ zIndex: z.NAV_LAYER, position: 'relative', }) + +export const navLogoGlyph = style({ + transition: 'filter 0.2s ease-in-out', + selectors: { + 'html[data-theme-mode="dark"] &': { + filter: 'invert(1)', + }, + }, +}) diff --git a/apps/web/src/layouts/CreateDaoLayout/Nav.tsx b/apps/web/src/layouts/CreateDaoLayout/Nav.tsx index 253682740..bbde47c7b 100644 --- a/apps/web/src/layouts/CreateDaoLayout/Nav.tsx +++ b/apps/web/src/layouts/CreateDaoLayout/Nav.tsx @@ -7,7 +7,7 @@ import React from 'react' import NogglesLogo from '../assets/builder-framed.svg' import TestnetLogo from '../assets/testnet.svg' import { NavMenu } from '../DefaultLayout/NavMenu' -import { NavContainer, navLogo, NavWrapper } from './Nav.styles.css' +import { NavContainer, navLogo, navLogoGlyph, NavWrapper } from './Nav.styles.css' export const Nav = () => { const isMounted = useIsMounted() @@ -21,8 +21,7 @@ export const Nav = () => { { @@ -29,8 +35,7 @@ export const Nav = () => { = ({ placement="bottom-end" close={activeDropdown !== MenuType.CHAIN_MENU} onOpenChange={(open) => onOpenMenu(open, MenuType.CHAIN_MENU)} + wrapperClassName={navPopUpWrapper} trigger={ { @@ -27,6 +28,7 @@ export const NavMenu = () => { return ( + = ({ )} - @@ -506,7 +508,7 @@ export const ProfileMenu: React.FC = ({ px={'x8'} gap={'x3'} backgroundColor="background1" - className={mobileMenuSlideIn} + className={[mobileMenuSlideIn, navPopUpWrapper]} style={{ width: '100vw', maxHeight: '100vh', @@ -560,6 +562,7 @@ export const ProfileMenu: React.FC = ({ trigger={triggerElement} close={activeDropdown !== MenuType.PROFILE_MENU} onOpenChange={handleOpenMenu} + wrapperClassName={navPopUpWrapper} > {address ? renderConnectedUser() : } diff --git a/apps/web/src/layouts/DefaultLayout/NavMenu/ThemeToggle.tsx b/apps/web/src/layouts/DefaultLayout/NavMenu/ThemeToggle.tsx new file mode 100644 index 000000000..44d8f723a --- /dev/null +++ b/apps/web/src/layouts/DefaultLayout/NavMenu/ThemeToggle.tsx @@ -0,0 +1,37 @@ +import { Box, Flex, Text } from '@buildeross/zord' +import React from 'react' +import { useThemeMode } from 'src/theme/AppThemeProvider' + +import { themeToggleButton } from '../Nav.styles.css' + +export const ThemeToggle = () => { + const [isMounted, setIsMounted] = React.useState(false) + const { mode, toggleMode } = useThemeMode() + const isDarkMode = mode === 'dark' + + React.useEffect(() => { + setIsMounted(true) + }, []) + + if (!isMounted) { + return + } + + return ( + + + + ) +} diff --git a/apps/web/src/modules/dashboard/UserProfileCard.css.ts b/apps/web/src/modules/dashboard/UserProfileCard.css.ts index eccc817e8..8562b9358 100644 --- a/apps/web/src/modules/dashboard/UserProfileCard.css.ts +++ b/apps/web/src/modules/dashboard/UserProfileCard.css.ts @@ -1,6 +1,8 @@ import { atoms, color, space } from '@buildeross/zord' import { style } from '@vanilla-extract/css' +const dashboardActionGrey = '#5b5b61' + export const profileCard = style([ atoms({ padding: 'x4', @@ -11,9 +13,16 @@ export const profileCard = style([ }), { cursor: 'pointer', - transition: 'background-color 0.15s ease', + transition: 'background-color 0.15s ease, border-color 0.15s ease', ':hover': { - backgroundColor: color.border, + backgroundColor: color.background2, + borderColor: color.background2, + }, + selectors: { + 'html[data-theme-mode="dark"] &:hover': { + backgroundColor: dashboardActionGrey, + borderColor: dashboardActionGrey, + }, }, }, ]) diff --git a/apps/web/src/modules/explore/ExploreToolbar.tsx b/apps/web/src/modules/explore/ExploreToolbar.tsx index 9f5b91e99..42e0c0833 100644 --- a/apps/web/src/modules/explore/ExploreToolbar.tsx +++ b/apps/web/src/modules/explore/ExploreToolbar.tsx @@ -41,7 +41,8 @@ export const ExploreToolbar: React.FC = ({ mb={'x4'} mx={'x4'} style={{ - borderBottom: pathname === '/explore' ? `2px solid black` : `0px`, + borderBottom: + pathname === '/explore' ? `2px solid ${vars.color.text1}` : `0px`, }} > Explore @@ -53,7 +54,8 @@ export const ExploreToolbar: React.FC = ({ mb={'x4'} mx={'x4'} style={{ - borderBottom: pathname === '/mydaos' ? `2px solid black` : `0px`, + borderBottom: + pathname === '/mydaos' ? `2px solid ${vars.color.text1}` : `0px`, }} > My DAOs diff --git a/apps/web/src/modules/explore/SearchInput.css.ts b/apps/web/src/modules/explore/SearchInput.css.ts index 47c04d09d..6460ece3b 100644 --- a/apps/web/src/modules/explore/SearchInput.css.ts +++ b/apps/web/src/modules/explore/SearchInput.css.ts @@ -1,3 +1,4 @@ +import { vars } from '@buildeross/zord' import { style } from '@vanilla-extract/css' export const searchInputWrapper = style({ @@ -9,21 +10,22 @@ export const searchInputWrapper = style({ export const searchInputStyle = style({ height: 64, width: '100%', - backgroundColor: '#F2F2F2', + backgroundColor: vars.color.background2, borderRadius: '12px', fontSize: 16, paddingLeft: 16, paddingRight: 80, // Space for search icon boxSizing: 'border-box', - border: '2px solid #fff', + border: `2px solid ${vars.color.background1}`, + color: vars.color.text1, selectors: { '&:focus': { outline: 'none', - backgroundColor: '#FFF', - borderColor: '#E6E6E6', + backgroundColor: vars.color.background1, + borderColor: vars.color.border, }, '&::placeholder': { - color: '#B3B3B3', + color: vars.color.text3, }, }, }) @@ -80,7 +82,7 @@ export const helperTextStyle = style({ left: 16, marginTop: 4, fontSize: 14, - color: '#808080', + color: vars.color.text3, transition: 'opacity 0.2s ease-in-out', pointerEvents: 'none', whiteSpace: 'nowrap', diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index 41e3fb204..f55615d3d 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -28,6 +28,7 @@ import type { ReactElement, ReactNode } from 'react' import { Disclaimer } from 'src/components/Disclaimer' import { FrameProvider } from 'src/components/FrameProvider' import { LinksProvider } from 'src/components/LinksProvider' +import { AppThemeProvider } from 'src/theme/AppThemeProvider' import { clientConfig } from 'src/utils/clientConfig' import { SWRConfig } from 'swr' import { WagmiProvider } from 'wagmi' @@ -70,11 +71,13 @@ function App({ Component, pageProps, err }: AppPropsWithLayout) { options={{ showSpinner: false }} /> - - - {getLayout()} - - + + + + {getLayout()} + + + diff --git a/apps/web/src/pages/_document.tsx b/apps/web/src/pages/_document.tsx index 3935d9278..bdba26c48 100644 --- a/apps/web/src/pages/_document.tsx +++ b/apps/web/src/pages/_document.tsx @@ -1,8 +1,35 @@ -import { lightTheme, ThemeProvider } from '@buildeross/zord' +import { baseTheme, darkTheme, lightTheme, root } from '@buildeross/zord' import Document, { Head, Html, Main, NextScript } from 'next/document' +import { DEFAULT_THEME_MODE, THEME_STORAGE_KEY } from 'src/theme/theme' export default class MyDocument extends Document { render() { + const themeInitScript = ` + (function() { + var storageKey = '${THEME_STORAGE_KEY}'; + var defaultMode = '${DEFAULT_THEME_MODE}'; + var lightThemeClass = '${lightTheme}'; + var darkThemeClass = '${darkTheme}'; + var mode = defaultMode; + + try { + var storedTheme = window.localStorage.getItem(storageKey); + if (storedTheme === 'light' || storedTheme === 'dark') { + mode = storedTheme; + } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + mode = 'dark'; + } + } catch (error) {} + + document.documentElement.dataset.themeMode = mode; + document.documentElement.style.colorScheme = mode; + + var themeClass = mode === 'dark' ? darkThemeClass : lightThemeClass; + document.body.classList.remove(lightThemeClass, darkThemeClass); + document.body.classList.add(themeClass); + })(); + ` + return ( @@ -28,10 +55,11 @@ export default class MyDocument extends Document { crossOrigin="anonymous" /> - + +