diff --git a/README.md b/README.md index 83d1bb30..b41eb496 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,5 @@ file within the `src/apps` directory to learn how to get started. ⚠️ **Encountering 429 errors?** You may have hit a rate limit for the Etherspot service. PillarX will keep trying but it might be easier for you to register your own API keys for the Etherspot Bundler API and Etherspot Data Service API via the [Portal](https://portal.etherspot.io). ⚠️ **Privy encountering login limits?** You may have hit the maximum user limit for free accounts on Privy. Feel free to [register for Privy](https://dashboard.privy.io) and create an app. This will give you your own App ID which you can use in the `.env` file. + +⚠️ **ReOwn Account Needed For WalletConnect features** Sign up for a [ReOwn account](https://reown.com/), then copy your Project ID into the `.env` file. diff --git a/nixpacks.toml b/nixpacks.toml new file mode 100644 index 00000000..dc3f039a --- /dev/null +++ b/nixpacks.toml @@ -0,0 +1,34 @@ +providers = ["node"] + +# Global environment variables for low-memory builds/runtime +[variables] +NODE_ENV = "production" +NODE_OPTIONS = "--max-old-space-size=2048" +# Keep npm lean and quiet +NPM_CONFIG_FUND = "false" +NPM_CONFIG_AUDIT = "false" +CI = "true" + +# Tools available in the image +[phases.setup] +nixPkgs = [ + "nodejs_20", # Correct Nix package name (underscore, not dash) + "caddy" # Lightweight static file server for runtime +] + +# Deterministic, memory-friendly install +[phases.install] +cmds = [ + # Use npm ci (respects package-lock), avoid extra network/audit noise + "npm ci --prefer-offline --no-audit --no-fund" +] + +# Build the static site (outputs to ./build via vite.config.mjs) +[phases.build] +cmds = [ + "npm run build" +] + +# Serve the built assets with Caddy (very low memory footprint) +[start] +cmd = "sh -c 'caddy file-server --root build --listen :${PORT:-3000}'" diff --git a/src/apps/pillardao/components/AnimatedTitle.tsx b/src/apps/pillardao/components/AnimatedTitle.tsx new file mode 100644 index 00000000..70a4f0ce --- /dev/null +++ b/src/apps/pillardao/components/AnimatedTitle.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useState } from 'react'; +import { animated, useTrail } from '@react-spring/web'; +import styled from 'styled-components'; + +type AnimatedTitleProps = { + text: string; +}; + +const AnimatedTitle: React.FC = ({ text }) => { + const [isDisplaying, setIsDisplaying] = useState(true); + const letters = text.split(''); + + const trail = useTrail(letters.length, { + from: { opacity: 0, transform: 'translateY(32px)' }, + to: { + opacity: isDisplaying ? 1 : 0, + transform: isDisplaying ? 'translateY(0px)' : 'translateY(32px)', + }, + config: { tension: 210, friction: 20, mass: 1, duration: 25 }, + }); + + useEffect(() => { + const timeout = setTimeout(() => setIsDisplaying(false), 1250); + return () => clearTimeout(timeout); + }, []); + + return ( + +
+ {trail.map((styles, i) => ( + // eslint-disable-next-line react/no-array-index-key + + {letters[i] === ' ' ? '\u00A0' : letters[i]} + + ))} +
+
+ ); +}; + +const Overlay = styled.div` + position: fixed; + inset: 0; + display: flex; + justify-content: center; + align-items: center; + background: ${({ theme }) => theme.color.background.body}; + z-index: 9999; + + .title { + font-size: 48px; + font-weight: 800; + color: #fff; + } + + .letter { + display: inline-block; + } + + @media (max-width: 640px) { + .title { + font-size: 24px; + } + } +`; + +export default AnimatedTitle; diff --git a/src/apps/pillardao/components/CopyHelp.tsx b/src/apps/pillardao/components/CopyHelp.tsx new file mode 100644 index 00000000..f2e403a3 --- /dev/null +++ b/src/apps/pillardao/components/CopyHelp.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import styled from 'styled-components'; +import walletConnectImage from '../images/wallet-connect-example.png'; + +type CopyHelpProps = { + imageSrc?: string; + overlayCollapsed?: string; + overlayExpanded?: string; +}; + +// Displays the provided screenshot with an overlay arrow pointing at the copy icon +const CopyHelp: React.FC = ({ + imageSrc = walletConnectImage, + overlayCollapsed = 'How to copy the WC URI', + overlayExpanded = 'Tap to collapse', +}) => { + const [expanded, setExpanded] = React.useState(false); + + return ( + +
setExpanded((v) => !v)} + aria-pressed={expanded} + > + WalletConnect screenshot + + {/* In-overlay toggle helper */} + {expanded ? overlayExpanded : overlayCollapsed} +
+
+ ); +}; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const Figure = styled.button<{ $expanded: boolean }>` + position: relative; + display: inline-block; + width: auto; + height: ${({ $expanded }) => ($expanded ? 'auto' : '146px')}; + max-width: ${({ $expanded }) => ($expanded ? '640px' : '260px')}; + border-radius: 12px; + background: ${({ theme }) => theme.color.background.card}; + border: 1px solid ${({ theme }) => theme.color.border.alertOutline}; + cursor: pointer; + transition: max-width 0.2s ease-in-out, height 0.2s ease-in-out, transform 0.1s ease-in-out; + box-shadow: 0 4px 16px rgba(0,0,0,0.2); + overflow: hidden; /* keep overlays clipped to the figure */ + padding: 0; + + &:active { + transform: scale(0.995); + } +`; + +const Img = styled.img<{ $expanded: boolean }>` + display: block; + width: ${({ $expanded }) => ($expanded ? '100%' : 'auto')}; + height: ${({ $expanded }) => ($expanded ? 'auto' : '100%')}; + object-fit: contain; /* ensure full image is visible */ + border-radius: 12px; + filter: ${({ $expanded }) => ($expanded ? 'none' : 'grayscale(100%)')}; + transition: filter 0.2s ease-in-out; +`; + +/* Arrow and highlight intentionally removed per request */ + +const OverlayPill = styled.div` + position: absolute; + left: 12px; + right: 12px; /* constrain within figure */ + bottom: 12px; + background: rgba(0, 0, 0, 0.6); + color: #fff; + padding: 4px 8px; + border-radius: 999px; + font-size: 10px; + font-weight: 600; + pointer-events: none; + white-space: nowrap; + max-width: calc(100% - 24px); + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; +`; + +export default CopyHelp; diff --git a/src/apps/pillardao/components/MembershipPanel.tsx b/src/apps/pillardao/components/MembershipPanel.tsx new file mode 100644 index 00000000..01dce0cb --- /dev/null +++ b/src/apps/pillardao/components/MembershipPanel.tsx @@ -0,0 +1,224 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useReadContract } from 'wagmi'; +import Card from '../../../components/Text/Card'; +import Button from '../../../components/Button'; +import placeholderNftImage from '../images/pillar-dao-member-nft.png'; +import { Section, MembershipRow, NftBox, Row } from './Styles'; + +type MembershipPanelProps = { + resolvedAddress: `0x${string}`; + nftContract: `0x${string}`; + daoContract: `0x${string}`; + chainId: number; + isConnected: boolean; +}; + +type PillarDaoMembershipCache = { + address: `0x${string}`; + chainId: number; + nftContract: `0x${string}`; + tokenId?: string; + depositTimestamp?: number; + updatedAt: number; +}; + +const membershipCacheKey = (chainId: number, address: `0x${string}`, nft: `0x${string}`) => + `pillardao:membership:${chainId}:${nft}:${address.toLowerCase()}`; + +const readMembershipCache = (chainId: number, address: `0x${string}`, nft: `0x${string}`): PillarDaoMembershipCache | null => { + try { + const raw = localStorage.getItem(membershipCacheKey(chainId, address, nft)); + if (!raw) return null; + return JSON.parse(raw) as PillarDaoMembershipCache; + } catch { return null; } +}; + +const writeMembershipCache = (entry: PillarDaoMembershipCache) => { + try { + localStorage.setItem(membershipCacheKey(entry.chainId, entry.address, entry.nftContract), JSON.stringify(entry)); + } catch { /* noop */ } +}; + +const MembershipPanel: React.FC = ({ resolvedAddress, nftContract, daoContract, chainId, isConnected }) => { + const [cached, setCached] = useState(null); + + useEffect(() => { + if (!isConnected) { setCached(null); return; } + const c = readMembershipCache(chainId, resolvedAddress, nftContract); + setCached(c); + }, [isConnected, chainId, resolvedAddress, nftContract]); + + const erc721BalanceAbi = [ + { inputs: [{ name: 'owner', type: 'address' }], name: 'balanceOf', outputs: [{ type: 'uint256' }], stateMutability: 'view', type: 'function' }, + ] as const; + const erc721EnumerableAbi = [ + { inputs: [{ name: 'owner', type: 'address' }, { name: 'index', type: 'uint256' }], name: 'tokenOfOwnerByIndex', outputs: [{ type: 'uint256' }], stateMutability: 'view', type: 'function' }, + ] as const; + const membershipTimestampAbi = [ + { inputs: [{ name: 'member', type: 'address' }], name: 'viewDepositTimestamp', outputs: [{ type: 'uint256' }], stateMutability: 'view', type: 'function' }, + ] as const; + + const nftBalanceRead = useReadContract({ + abi: erc721BalanceAbi, + address: nftContract, + functionName: 'balanceOf', + args: [resolvedAddress], + chainId, + query: { enabled: Boolean(isConnected) }, + }); + + const nftFirstTokenRead = useReadContract({ + abi: erc721EnumerableAbi, + address: nftContract, + functionName: 'tokenOfOwnerByIndex', + args: [resolvedAddress, BigInt(0)], + chainId, + query: { + enabled: Boolean(isConnected && (nftBalanceRead?.data as bigint) !== undefined && (nftBalanceRead?.data as bigint) > BigInt(0)), + }, + }); + + const effectiveMembershipId = useMemo(() => { + const balance = nftBalanceRead?.data as bigint | undefined; + if (!balance || balance === BigInt(0)) return BigInt(0); + const id = nftFirstTokenRead?.data as bigint | undefined; + if (id && id > BigInt(0)) return id; + const cachedId = cached?.tokenId ? BigInt(cached.tokenId) : BigInt(0); + return cachedId > BigInt(0) ? cachedId : BigInt(0); + }, [nftBalanceRead?.data, nftFirstTokenRead?.data, cached?.tokenId]); + + const depositTsRead = useReadContract({ + abi: membershipTimestampAbi, + address: daoContract, + functionName: 'viewDepositTimestamp', + args: [resolvedAddress], + chainId, + query: { enabled: Boolean(isConnected) }, + }); + + const effectiveDepositTs = useMemo(() => { + const onchain = Number(depositTsRead?.data || BigInt(0)); + if (onchain > 0) return onchain; + return cached?.depositTimestamp || 0; + }, [depositTsRead?.data, cached?.depositTimestamp]); + + const truncate = (addr?: string) => { + if (!addr) return ''; + const a = addr.trim(); + if (a.length <= 12) return a; + return `${a.slice(0, 6)}...${a.slice(-4)}`; + }; + + useEffect(() => { + if (!isConnected) return; + const tokenId = nftFirstTokenRead?.data as bigint | undefined; + const depositTs = Number(depositTsRead?.data || BigInt(0)); + const hasUseful = (tokenId && tokenId > BigInt(0)) || depositTs > 0; + if (!hasUseful) return; + writeMembershipCache({ + address: resolvedAddress, + chainId, + nftContract, + tokenId: tokenId && tokenId > BigInt(0) ? String(tokenId) : cached?.tokenId, + depositTimestamp: depositTs > 0 ? depositTs : (cached?.depositTimestamp || 0), + updatedAt: Date.now(), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isConnected, chainId, resolvedAddress, nftContract, nftFirstTokenRead?.data, depositTsRead?.data]); + + if (!isConnected) { + return ( +
+ + +
+
Connected Wallet
+
+
Membership
+
Not connected
+
+
+ +

Connect a wallet to view.

+
+
+
+
+
+ ); + } + + return ( +
+ + +
+
Connected Wallet
+
{truncate(resolvedAddress)}
+ {effectiveMembershipId > BigInt(0) && ( + <> +
Membership ID
+
{effectiveMembershipId.toString()}
+ + )} +
Member Since
+
+ {effectiveDepositTs > 0 + ? (() => { + const d = new Date(effectiveDepositTs * 1000); + const date = d.toLocaleDateString(); + const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return `${date} ${time}`; + })() + : '—'} +
+
+
+ + {effectiveMembershipId > BigInt(0) ? ( + PillarDAO Member NFT + ) : ( +

No Member NFT in this Pillar X wallet.

+ )} +
+ {effectiveMembershipId > BigInt(0) && ( + <> + + + + + + + + )} +
+
+
+
+ ); +}; + +export default MembershipPanel; + diff --git a/src/apps/pillardao/components/OnboardingPanel.tsx b/src/apps/pillardao/components/OnboardingPanel.tsx new file mode 100644 index 00000000..c2b0728d --- /dev/null +++ b/src/apps/pillardao/components/OnboardingPanel.tsx @@ -0,0 +1,160 @@ +import React, { useMemo, useState } from 'react'; +import Card from '../../../components/Text/Card'; +import Button from '../../../components/Button'; +import TextInput from '../../../components/Form/TextInput'; +import { useWalletConnect } from '../../../services/walletConnect'; +import useTransactionKit from '../../../hooks/useTransactionKit'; +import CopyHelp from './CopyHelp'; +import signInImage from '../images/wallet-connect-sign-in-example.png'; +import manifest from '../manifest.json'; +import { Section, Row, ConnectLayout, ConnectAside, ConnectInline, SmallNote, RightAddon, WalletInfo, ConnectError, SessionItem } from './Styles'; + +const OnboardingPanel: React.FC = () => { + const { connect, activeSessions, isLoadingConnect, disconnect, disconnectAllSessions } = useWalletConnect(); + const { walletAddress: accountAddress } = useTransactionKit(); + const hasSessions = useMemo(() => !!activeSessions && Object.keys(activeSessions).length > 0, [activeSessions]); + const [wcUri, setWcUri] = useState(''); + const [connectError, setConnectError] = useState(null); + + const COMMUNITY_URL = (manifest as any)?.links?.community; + const CHAT_URL = (manifest as any)?.links?.chat; + const CHAT_FALLBACK_URL = (manifest as any)?.links?.chatFallback; + const PILLAR_NFT_SIGNUP_URL = (manifest as any)?.links?.NFTsignup; + + const openCommunitySocial = async () => { + const communityBase = (COMMUNITY_URL || 'https://pillardao.org/').replace(/\/+$/, ''); + const primary = (CHAT_URL || `${communityBase}/chat`).replace(/\/+$/, ''); + const fallback = CHAT_FALLBACK_URL || 'https://discord.com/invite/t39xKhzSPb'; + try { + const res = await fetch(primary, { method: 'GET', cache: 'no-store' }); + await new Promise((r) => setTimeout(r, 10)); + if (res && res.status === 404) window.open(fallback, '_blank', 'noreferrer'); + else window.open(primary, '_blank', 'noreferrer'); + } catch { + await new Promise((r) => setTimeout(r, 10)); + window.open(primary, '_blank', 'noreferrer'); + } + }; + + return ( + <> +
+ + + 1) Open the Pillar DAO NFT web page + 2) Find the "How to become a Member" login + 3) Sign in with WalletConnect to connect the signup to this wallet + + {!hasSessions && ( + + + On the site, choose WalletConnect from the available wallets. When a QR code shows, tap “copy” (or “open in wallet”), then come back and paste the URI here. + + + )} + + + {!hasSessions && ( + <> + + + + )} + + + {!hasSessions && ( + <> + Paste a WalletConnect URI + { setWcUri(v); setConnectError(null); }} + rightAddon={ + + {!!wcUri && ( + + )} + + + } + /> + {!!connectError && ({connectError})} + + )} + {hasSessions && ( + <> + + + Your wallet + {(() => { + const a = (accountAddress || '').trim(); + if (!a) return ''; + if (a.length <= 12) return a; + return `${a.slice(0, 6)}...${a.slice(-4)}`; + })()} + + + + + + )} + + + + + + + + + + + +
+ {hasSessions && ( +
+ + {Object.values(activeSessions || {}).map((s: any) => ( + +
+ {s.peer?.metadata?.icons?.[0] ? (dApp) : null} +
+
{s.peer?.metadata?.name || 'dApp'}
+
{s.peer?.metadata?.url || ''}
+
+
+
+ +
+
+ ))} + + + +
+
+ )} + + ); +}; + +export default OnboardingPanel; diff --git a/src/apps/pillardao/components/Styles.tsx b/src/apps/pillardao/components/Styles.tsx new file mode 100644 index 00000000..d351c54b --- /dev/null +++ b/src/apps/pillardao/components/Styles.tsx @@ -0,0 +1,196 @@ +import styled from 'styled-components'; + +export const Section = styled.div` + margin-bottom: 16px; + &:last-child { margin-bottom: 0; } +`; + +export const Row = styled.div` + margin-top: 10px; +`; + +export const SmallNote = styled.p` + font-size: 12px; + color: ${({ theme }) => theme.color.text.cardContent}; + margin: 10px 0 0; +`; + +export const ProposalsBox = styled.div` + margin-top: 12px; + padding: 10px; + border-radius: 8px; + background: ${({ theme }) => theme.color.background.input}; + max-height: 50vh; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +`; + +export const MembershipRow = styled.div` + display: grid; + grid-template-columns: 1fr 200px; + gap: 12px; + align-items: start; + .label { font-size: 11px; opacity: 0.8; } + .value { font-size: 13px; margin-bottom: 6px; } + @media (max-width: 540px) { grid-template-columns: 1fr; } +`; + +export const NftBox = styled.div` + display: flex; + align-items: center; + justify-content: center; + background: ${({ theme }) => theme.color.background.selectItem}; + border: 1px solid ${({ theme }) => theme.color.border.alertOutline}; + border-radius: 8px; + min-height: 180px; + text-align: center; + p { margin: 0; line-height: 1.4; padding: 0 8px; color: ${({ theme }) => theme.color.text.cardContent}; } + img { max-width: 100%; max-height: 180px; border-radius: 6px; object-fit: contain; } +`; + +export const ConnectLayout = styled.div` + display: flex; + align-items: flex-start; + gap: 12px; + flex-wrap: nowrap; + @media (max-width: 640px) { flex-wrap: wrap; } +`; + +export const Previews = styled.div` + display: flex; + align-items: flex-start; + gap: 10px; + flex: 1 1 280px; + min-width: 0; + flex-wrap: wrap; +`; + +export const ConnectAside = styled.div<{ $fullWidth?: boolean }>` + width: ${({ $fullWidth }) => ($fullWidth ? '100%' : '320px')}; + max-width: 100%; + flex: ${({ $fullWidth }) => ($fullWidth ? '1 1 auto' : '0 0 320px')}; +`; + +export const SuccessNote = styled.div` + margin-top: 8px; + font-size: 12px; + color: ${({ theme }) => theme.color.text.cardContent}; +`; + +export const ConnectError = styled.div` + margin-top: 8px; + font-size: 12px; + color: ${({ theme }) => theme.color.text.transactionStatus.failed}; +`; + +export const ConnectStatus = styled.div` + margin-top: 8px; + font-size: 12px; + color: ${({ theme }) => theme.color.text.cardContent}; +`; + +export const RightAddon = styled.div` + display: inline-flex; + align-items: center; + gap: 8px; + height: 100%; + & > * { margin-bottom: 0 !important; } +`; + +export const WalletInfo = styled.div` + display: block; + padding: 10px 12px; + background: ${({ theme }) => theme.color.background.selectItem}; + border: 1px solid ${({ theme }) => theme.color.border.alertOutline}; + border-radius: 8px; + color: ${({ theme }) => theme.color.text.cardContent}; + width: 100%; + box-sizing: border-box; + margin-top: 8px; + .label { display: inline-block; font-weight: 600; opacity: 0.9; margin-right: 6px; } + .value { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; letter-spacing: 0.2px; word-break: break-all; } +`; + +export const ConnectInline = styled.div<{ $singleColumn?: boolean }>` + display: flex; + flex-direction: column; + gap: 8px; + align-items: stretch; + width: 100%; + @media (max-width: 480px) { + gap: 6px; + } +`; + +export const ProposalItem = styled.div` + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + padding: 8px 0; + gap: 10px; + border-bottom: 1px solid ${({ theme }) => theme.color.border.cardContentHorizontalSeparator}; + border-radius: 6px; + transition: background 0.12s ease-in-out; + &:hover { background: ${({ theme }) => theme.color.background.selectItem}; } + &:last-child { border-bottom: none; } + .title { font-size: 15px; color: ${({ theme }) => theme.color.background.buttonPrimary}; display: flex; align-items: center; gap: 8px; } + .status { font-size: 12px; color: ${({ theme }) => theme.color.text.cardContent}; } + .actions a { font-size: 12px; color: ${({ theme }) => theme.color.text.cardLink}; text-decoration: underline; } +`; + +export const RowInline = styled.div` + display: inline-flex; + align-items: center; + gap: 8px; +`; + +export const ProposalBody = styled.div` + padding: 10px; + margin: 6px 0 10px 0; + font-size: 12px; + color: ${({ theme }) => theme.color.text.cardContent}; + white-space: pre-wrap; + background: ${({ theme }) => theme.color.background.selectItem}; + border: 1px solid ${({ theme }) => theme.color.border.alertOutline}; + border-radius: 8px; + position: relative; + .content { padding-right: 28px; max-height: 240px; overflow: auto; -webkit-overflow-scrolling: touch; } + .meta { margin-top: 8px; font-size: 11px; opacity: 0.9; } +`; + +export const CollapseButton = styled.button` + position: absolute; + right: 8px; + bottom: 8px; + background: transparent; + border: none; + color: ${({ theme }) => theme.color.text.cardLink}; + cursor: pointer; + display: inline-flex; + align-items: center; + padding: 4px; + border-radius: 6px; + &:hover { background: ${({ theme }) => theme.color.background.input}; } +`; + +export const SessionList = styled.div` + margin: 8px 0 12px; + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const SessionItem = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px; + border-radius: 6px; + background: ${({ theme }) => theme.color.background.input}; + .meta { display: flex; align-items: center; gap: 10px; } + img { width: 24px; height: 24px; border-radius: 4px; object-fit: cover; } + .name { font-size: 13px; font-weight: 600; } + .url { font-size: 11px; opacity: 0.8; } +`; diff --git a/src/apps/pillardao/components/VotingPanel.tsx b/src/apps/pillardao/components/VotingPanel.tsx new file mode 100644 index 00000000..dda100f6 --- /dev/null +++ b/src/apps/pillardao/components/VotingPanel.tsx @@ -0,0 +1,256 @@ +import React, { useState } from 'react'; +import Card from '../../../components/Text/Card'; +import Button from '../../../components/Button'; +import { ExportSquare as IconExportSquare, ArrowRight2 as IconArrowRight, ArrowDown2 as IconArrowDown, ArrowUp2 as IconArrowUp } from 'iconsax-react'; +import manifest from '../manifest.json'; +import { Section, Row, ProposalsBox, ProposalItem, ProposalBody, CollapseButton } from './Styles'; + +const VotingPanel: React.FC = () => { + const [isLoadingProposals, setIsLoadingProposals] = useState(false); + const [proposalsError, setProposalsError] = useState(null); + const [showProposals, setShowProposals] = useState(false); + const [proposalsSkip, setProposalsSkip] = useState(0); + const [hasMoreProposals, setHasMoreProposals] = useState(true); + const [expandedProposalId, setExpandedProposalId] = useState(null); + const [proposals, setProposals] = useState< + { id: string; title: string; state?: string; link?: string; end?: number; body?: string; created?: number }[] + >([]); + + const COMMUNITY_URL = (manifest as any)?.links?.community; + const VOTING_URL = (manifest as any)?.links?.voting; + const VOTING_FALLBACK_URL = (manifest as any)?.links?.votingFallback; + + const renderProposalStatus = (state?: string, end?: number) => { + if (!state) return ''; + if (state === 'active' && end) { + const now = Math.floor(Date.now() / 1000); + const remaining = Math.max(0, end - now); + if (remaining <= 0) return 'Ended'; + const days = Math.floor(remaining / 86400); + const hours = Math.floor((remaining % 86400) / 3600); + const mins = Math.floor((remaining % 3600) / 60); + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours || days) parts.push(`${hours}h`); + parts.push(`${mins}m`); + return `Ends in ${parts.join(' ')}`; + } + return state; + }; + + const timeAgo = (timestamp?: number) => { + if (!timestamp) return ''; + const now = Math.floor(Date.now() / 1000); + let diff = Math.max(0, now - timestamp); + const units: [number, string][] = [ + [31536000, 'y'], + [2592000, 'mo'], + [604800, 'w'], + [86400, 'd'], + [3600, 'h'], + [60, 'm'], + ]; + for (const [sec, label] of units) { + if (diff >= sec) { + const val = Math.floor(diff / sec); + return `${val}${label} ago`; + } + } + return 'just now'; + }; + + const openVoting = async () => { + const communityBase = (COMMUNITY_URL || 'https://pillardao.org/').replace(/\/+$/, ''); + const primary = (VOTING_URL || `${communityBase}/voting`).replace(/\/+$/, ''); + const fallback = VOTING_FALLBACK_URL || 'https://snapshot.box/#/s:plrdao.eth/'; + + try { + const res = await fetch(primary, { method: 'GET', cache: 'no-store' }); + await new Promise((r) => setTimeout(r, 10)); + if (res && res.status === 404) { + window.open(fallback, '_blank', 'noreferrer'); + } else { + window.open(primary, '_blank', 'noreferrer'); + } + } catch (e) { + await new Promise((r) => setTimeout(r, 10)); + window.open(primary, '_blank', 'noreferrer'); + } + }; + + const loadLatestProposals = async () => { + setIsLoadingProposals(true); + setProposalsError(null); + setShowProposals(true); + + const snapshotUrl = 'https://snapshot.box/#/s:plrdao.eth/'; + + try { + const gql = 'https://hub.snapshot.org/graphql'; + const body = { + query: + 'query Proposals($space: String!, $first: Int!, $skip: Int!) { proposals(first: $first, skip: $skip, where: { space_in: [$space] }, orderBy: "created", orderDirection: desc) { id title state end created body space { id } } }', + variables: { space: 'plrdao.eth', first: 3, skip: 0 }, + }; + const res = await fetch(gql, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('Snapshot API error'); + const json = await res.json(); + const items = json?.data?.proposals || []; + if (!items.length) throw new Error('No proposals'); + + const mapped = + items.map((p: any) => ({ + id: p.id, + title: p.title, + state: p.state, + end: p.end, + created: p.created, + body: p.body, + link: `${snapshotUrl}proposal/${p.id}`, + })); + setProposals(mapped); + setProposalsSkip(items.length); + setHasMoreProposals(items.length >= 3); + } catch (e) { + setProposalsError('Could not load proposals'); + setProposals([]); + } finally { + setIsLoadingProposals(false); + } + }; + + const loadMoreProposals = async () => { + setIsLoadingProposals(true); + setProposalsError(null); + const snapshotUrl = 'https://snapshot.box/#/s:plrdao.eth/'; + + try { + const gql = 'https://hub.snapshot.org/graphql'; + const body = { + query: + 'query Proposals($space: String!, $first: Int!, $skip: Int!) { proposals(first: $first, skip: $skip, where: { space_in: [$space] }, orderBy: "created", orderDirection: desc) { id title state end created body space { id } } }', + variables: { space: 'plrdao.eth', first: 20, skip: proposalsSkip }, + }; + const res = await fetch(gql, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error('Snapshot API error'); + const json = await res.json(); + const items = Array.isArray(json?.data?.proposals) + ? json.data.proposals + : []; + if (!items.length) { + setHasMoreProposals(false); + return; + } + + const mapped = items.map((p: any) => ({ + id: p.id, + title: p.title, + state: p.state, + end: p.end, + created: p.created, + body: p.body, + link: `${snapshotUrl}proposal/${p.id}`, + })); + + setProposals((prev) => [...prev, ...mapped]); + setProposalsSkip((prev) => prev + items.length); + setHasMoreProposals(items.length >= 20); + } catch (e) { + setProposalsError('Could not load more proposals'); + } finally { + setIsLoadingProposals(false); + } + }; + + return ( +
+ + {!showProposals && ( + + + + )} + + {showProposals && ( + + {isLoadingProposals &&

Loading latest proposals...

} + {!isLoadingProposals && proposalsError && ( + + + + )} + {!isLoadingProposals && !proposalsError && ( + <> + {proposals.map((p) => ( +
+ setExpandedProposalId(expandedProposalId === p.id ? null : p.id)}> +
+ + {expandedProposalId === p.id ? ( + + ) : ( + + )} + + {p.title} +
+
{renderProposalStatus(p.state, p.end)}
+
e.stopPropagation()}> + + + +
+
+ {expandedProposalId === p.id && ( + +
{p.body?.slice(0, 1000) || 'No description available.'}
+
Posted {timeAgo(p.created)}
+ setExpandedProposalId(null)}> + Collapse + +
+ )} +
+ ))} + {hasMoreProposals && ( + + + + )} + + )} +
+ )} + + + + +
+
+ ); +}; + +export default VotingPanel; + diff --git a/src/apps/pillardao/icon.png b/src/apps/pillardao/icon.png new file mode 100644 index 00000000..8d560952 Binary files /dev/null and b/src/apps/pillardao/icon.png differ diff --git a/src/apps/pillardao/images/pillar-dao-image.png b/src/apps/pillardao/images/pillar-dao-image.png new file mode 100644 index 00000000..72a3bb63 Binary files /dev/null and b/src/apps/pillardao/images/pillar-dao-image.png differ diff --git a/src/apps/pillardao/images/pillar-dao-member-nft.png b/src/apps/pillardao/images/pillar-dao-member-nft.png new file mode 100644 index 00000000..4b573a88 Binary files /dev/null and b/src/apps/pillardao/images/pillar-dao-member-nft.png differ diff --git a/src/apps/pillardao/images/wallet-connect-example.png b/src/apps/pillardao/images/wallet-connect-example.png new file mode 100644 index 00000000..65294d0e Binary files /dev/null and b/src/apps/pillardao/images/wallet-connect-example.png differ diff --git a/src/apps/pillardao/images/wallet-connect-sign-in-example.png b/src/apps/pillardao/images/wallet-connect-sign-in-example.png new file mode 100644 index 00000000..df58d536 Binary files /dev/null and b/src/apps/pillardao/images/wallet-connect-sign-in-example.png differ diff --git a/src/apps/pillardao/index.test.tsx b/src/apps/pillardao/index.test.tsx new file mode 100644 index 00000000..1a6f4231 --- /dev/null +++ b/src/apps/pillardao/index.test.tsx @@ -0,0 +1,192 @@ +// PillarDAO tests: run only this app's tests +// - Single file (CI-style): +// npm run -s test -- run src/apps/pillardao/index.test.tsx +// - All PillarDAO tests (glob): +// npm run -s test -- run "src/apps/pillardao/**/*.test.tsx" +// - Watch this file locally (if you prefer watch mode): +// npx vitest watch src/apps/pillardao/index.test.tsx +// +// The splash intro is skipped in test mode so tabs render immediately. + + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, beforeEach, vi, expect } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; + +import { defaultTheme } from '../../theme'; + +// Mock hooks used by the sub-app to keep UI simple in tests +vi.mock('./components/AnimatedTitle', () => ({ + default: ({ text }: { text: string }) => text, +})); + +vi.mock('../../hooks/useTransactionKit', () => ({ + default: () => ({ walletAddress: undefined }), +})); + +vi.mock('../../services/walletConnect', () => ({ + useWalletConnect: () => ({ + connect: vi.fn(), + disconnect: vi.fn(), + disconnectAllSessions: vi.fn(), + activeSessions: {}, + isLoadingConnect: false, + isLoadingDisconnect: false, + isLoadingDisconnectAll: false, + }), +})); + +vi.mock('../../hooks/useWalletConnectToast', () => ({ + default: () => ({ showToast: vi.fn() }), +})); + +// Override wagmi for this test: we don't need real provider behavior, +// just ensure hooks render without throwing and record calls if needed. +vi.mock('wagmi', () => ({ + WagmiProvider: ({ children }: any) => children, + createConfig: vi.fn(() => ({})), + useReadContract: vi.fn(() => ({ data: undefined, refetch: vi.fn() })), +})); + +// Import after mocks +import PillarDaoApp from './index'; + +describe('PillarDAO Sub-app', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderApp = () => + render( + + + + + + ); + + it('renders header and tabs', async () => { + renderApp(); + expect(await screen.findByText('Voting')).toBeInTheDocument(); + expect(await screen.findByText('Join PillarDAO')).toBeInTheDocument(); + expect(await screen.findByText('My DAO NFT')).toBeInTheDocument(); + }); + + it('shows WalletConnect URI input when no sessions are active', async () => { + renderApp(); + fireEvent.click(await screen.findByText('Join PillarDAO')); + expect( + await screen.findByPlaceholderText('wc:...') + ).toBeInTheDocument(); + }); + + it('opens voting link (primary) when clicking Open PillarDAO voting', async () => { + renderApp(); + + // Mock network and window.open used by openVoting + const fetchSpy = vi + .spyOn(global, 'fetch' as any) + .mockResolvedValue({ ok: true, status: 200 } as any); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + const btn = (await screen.findAllByText('Open PillarDAO voting'))[0]; + fireEvent.click(btn); + + // allow micro waits inside the handler + await new Promise((r) => setTimeout(r, 20)); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://pillardao.org/voting', + expect.objectContaining({ method: 'GET' }) + ); + expect(openSpy).toHaveBeenCalledWith( + 'https://pillardao.org/voting', + '_blank', + 'noreferrer' + ); + }); + + it('falls back to Snapshot when voting URL 404s', async () => { + renderApp(); + + const fetchSpy = vi + .spyOn(global, 'fetch' as any) + .mockResolvedValue({ ok: false, status: 404 } as any); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + const btn = (await screen.findAllByText('Open PillarDAO voting'))[0]; + fireEvent.click(btn); + + await new Promise((r) => setTimeout(r, 20)); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://pillardao.org/voting', + expect.objectContaining({ method: 'GET' }) + ); + expect(openSpy).toHaveBeenCalledWith( + 'https://snapshot.box/#/s:plrdao.eth/', + '_blank', + 'noreferrer' + ); + }); + + it('opens community chat link when clicking community button', async () => { + renderApp(); + + const fetchSpy = vi + .spyOn(global, 'fetch' as any) + .mockResolvedValue({ ok: true, status: 200 } as any); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + fireEvent.click(await screen.findByText('Join PillarDAO')); + const btn = await screen.findByText('Join Pillar DAO chat and community'); + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 20)); + + // With current manifest, primary is community base + '/chat' + expect(fetchSpy).toHaveBeenCalledWith( + 'https://pillardao.org/chat', + expect.objectContaining({ method: 'GET' }) + ); + expect(openSpy).toHaveBeenCalledWith( + 'https://pillardao.org/chat', + '_blank', + 'noreferrer' + ); + }); + + it('falls back to Discord invite when community chat URL 404s', async () => { + renderApp(); + + const fetchSpy = vi + .spyOn(global, 'fetch' as any) + .mockResolvedValue({ ok: false, status: 404 } as any); + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + + fireEvent.click(await screen.findByText('Join PillarDAO')); + const btn = await screen.findByText('Join Pillar DAO chat and community'); + fireEvent.click(btn); + await new Promise((r) => setTimeout(r, 20)); + + expect(fetchSpy).toHaveBeenCalledWith( + 'https://pillardao.org/chat', + expect.objectContaining({ method: 'GET' }) + ); + expect(openSpy).toHaveBeenCalledWith( + 'https://discord.com/invite/t39xKhzSPb', + '_blank', + 'noreferrer' + ); + }); + + it('shows NFT signup card when Join tab selected', async () => { + renderApp(); + fireEvent.click(await screen.findByText('Join PillarDAO')); + expect(await screen.findByText('Get Your Membership')).toBeInTheDocument(); + expect( + await screen.findByText('Open Pillar DAO Member Signup') + ).toBeInTheDocument(); + }); +}); diff --git a/src/apps/pillardao/index.tsx b/src/apps/pillardao/index.tsx new file mode 100644 index 00000000..3a0572b1 --- /dev/null +++ b/src/apps/pillardao/index.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { useNavigate } from 'react-router-dom'; +// Uses the app's global WagmiProvider; no local provider here +import manifest from './manifest.json'; +import headerImage from './images/pillar-dao-image.png'; +import AnimatedTitle from './components/AnimatedTitle'; +import VotingPanel from './components/VotingPanel'; +import OnboardingPanel from './components/OnboardingPanel'; +import MembershipPanel from './components/MembershipPanel'; +import useTransactionKit from '../../hooks/useTransactionKit'; + + +const PillarDaoInner = () => { + const navigate = useNavigate(); + const isTestMode = + typeof process !== 'undefined' && + typeof process.env !== 'undefined' && + process.env.NODE_ENV === 'test'; + const introMs = Math.max(0, Number((manifest as any)?.introMs ?? 1500)); + const [showIntro, setShowIntro] = useState(!(isTestMode || introMs === 0)); + const { walletAddress } = useTransactionKit(); + const [activeTab, setActiveTab] = useState<'voting' | 'onboarding' | 'membership'>('voting'); + const [headerImgOk, setHeaderImgOk] = useState(true); + + const goHome = (e?: React.MouseEvent) => { + if (e) e.preventDefault(); + setActiveTab('onboarding'); + try { navigate('/pillardao'); } catch {} + }; + + useEffect(() => { + if (isTestMode || introMs === 0) { + setShowIntro(false); + return; + } + const t = setTimeout(() => setShowIntro(false), introMs); + return () => clearTimeout(t); + }, [isTestMode, introMs]); + + const CONTRACTS = (manifest as any)?.contracts || {}; + const POLYGON_CHAIN_ID = Number(CONTRACTS.chainId); + const NFT_CONTRACT = CONTRACTS.nft as `0x${string}`; + const DAO_CONTRACT = CONTRACTS.dao as `0x${string}`; + const resolvedAddress = (walletAddress || '0x0000000000000000000000000000000000000000') as `0x${string}`; + + const COMMUNITY_URL = (manifest as any)?.links?.community; + const VOTING_URL = (manifest as any)?.links?.voting; + const VOTING_FALLBACK_URL = (manifest as any)?.links?.votingFallback; + const CHAT_URL = (manifest as any)?.links?.chat; + const CHAT_FALLBACK_URL = (manifest as any)?.links?.chatFallback; + const PILLAR_NFT_SIGNUP_URL = (manifest as any)?.links?.NFTsignup; + + if (showIntro) { + return ; + } + + return ( + +
+ + Pillar DAO { if (headerImgOk) setHeaderImgOk(false); }} + /> + + {!headerImgOk && ( + +

Pillar DAO

+
+ )} +

Get your membership NFT and join socials.

+
+ + + setActiveTab('voting')} + > + Voting + + setActiveTab('membership')} + > + My DAO NFT + + setActiveTab('onboarding')} + > + Join PillarDAO + + + {activeTab === 'onboarding' && ( + + )} + {activeTab === 'voting' && ( + + )} + {activeTab === 'membership' && ( + + )} + + {/* Panels render their own content */} +
+ ); +}; + +const Wrapper = styled.div` + padding: 20px; + max-width: 800px; + margin: 0 auto; + font-size: 16px; + line-height: 1.5; +`; + +const Header = styled.div` + margin: 10px 0 18px; + + img { + display: block; + width: auto; /* keep intrinsic width */ + height: auto; /* keep intrinsic height */ + max-width: 100%; /* shrink if container is smaller */ + margin-bottom: 10px; + } + + h1 { + font-size: 24px; + font-weight: 700; + margin: 0 0 6px; + } + + .fallbackTitle { + font-size: 14px; + font-weight: 600; + } + + p { + font-size: 14px; + color: ${({ theme }) => theme.color.text.cardContent}; + } +`; + +const HeaderLink = styled.a` + display: inline-block; + text-decoration: none; + color: inherit; + cursor: pointer; +`; + +const Tabs = styled.div` + display: flex; + gap: 8px; + margin-bottom: 12px; +`; + +const TabButton = styled.button<{ $active?: boolean }>` + padding: 8px 12px; + border-radius: 6px; /* match Button rounding */ + border: 1px solid + ${({ theme }) => theme.color.border.alertOutline}; + background: ${({ theme, $active }) => + $active ? theme.color.background.card : 'transparent'}; + color: ${({ theme }) => theme.color.text.cardTitle}; + font-size: 13px; + cursor: pointer; +`; + +const PillarDaoApp = () => ; + +export default PillarDaoApp; diff --git a/src/apps/pillardao/manifest.json b/src/apps/pillardao/manifest.json new file mode 100644 index 00000000..79540ab2 --- /dev/null +++ b/src/apps/pillardao/manifest.json @@ -0,0 +1,23 @@ +{ + "title": "Pillar DAO", + "description": "Get your DAO membership NFT, join DAO socials, and vote on Snapshot.", + "links": { + "NFTsignup": "https://pillardao.org/#governor", + "community": "https://pillardao.org/", + "voting": "https://pillardao.org/voting", + "chat": "https://pillardao.org/chat", + "votingFallback": "https://snapshot.box/#/s:plrdao.eth/", + "chatFallback": "https://discord.com/invite/t39xKhzSPb" + }, + "contracts": { + "dao": "0xc380f15Db7be87441d0723F19fBb440AEaa734aB", + "nft": "0xfa2d028ba398c20ee0a7483c00218f91ffee47c6", + "chainId": 137 + }, + "translations": { + "en": { + "title": "Pillar DAO", + "description": "Get your membership NFT, join our socials, and vote on DAO proposals." + } + } +} diff --git a/src/apps/pillarx-app/components/EditorialTile/test/__snapshots__/EditorialTile.test.tsx.snap b/src/apps/pillarx-app/components/EditorialTile/test/__snapshots__/EditorialTile.test.tsx.snap index 77403caa..9ddcfb0d 100644 --- a/src/apps/pillarx-app/components/EditorialTile/test/__snapshots__/EditorialTile.test.tsx.snap +++ b/src/apps/pillarx-app/components/EditorialTile/test/__snapshots__/EditorialTile.test.tsx.snap @@ -128,7 +128,7 @@ exports[` > renders correctly and matches snapshot 1`] = `

- 1 October 2021 + 30 September 2021

diff --git a/src/apps/pillarx-app/components/TokenMarketDataRow/tests/LeftColumnTokenMarketDataRow.test.tsx b/src/apps/pillarx-app/components/TokenMarketDataRow/tests/LeftColumnTokenMarketDataRow.test.tsx index 7b6137da..e73ad733 100644 --- a/src/apps/pillarx-app/components/TokenMarketDataRow/tests/LeftColumnTokenMarketDataRow.test.tsx +++ b/src/apps/pillarx-app/components/TokenMarketDataRow/tests/LeftColumnTokenMarketDataRow.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from '@testing-library/react'; +import { vi } from 'vitest'; // components import LeftColumnTokenMarketDataRow from '../LeftColumnTokenMarketDataRow'; @@ -33,6 +34,16 @@ const ethTokenRow = { }; describe(' - ETH token row', () => { + beforeEach(() => { + // Mock the current date to December 22, 2025 for consistent relative time calculations + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-12-22T00:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it('renders and matches snapshot', () => { const tree = render(); expect(tree).toMatchSnapshot(); diff --git a/src/apps/pillarx-app/components/TokenMarketDataRow/tests/__snapshots__/LeftColumnTokenMarketDataRow.test.tsx.snap b/src/apps/pillarx-app/components/TokenMarketDataRow/tests/__snapshots__/LeftColumnTokenMarketDataRow.test.tsx.snap index 5ffd8bd8..23244644 100644 --- a/src/apps/pillarx-app/components/TokenMarketDataRow/tests/__snapshots__/LeftColumnTokenMarketDataRow.test.tsx.snap +++ b/src/apps/pillarx-app/components/TokenMarketDataRow/tests/__snapshots__/LeftColumnTokenMarketDataRow.test.tsx.snap @@ -41,7 +41,7 @@ exports[` - ETH token row > renders and matches

- 5mo ago + 8mo ago

- ETH token row > renders and matches

- 5mo ago + 8mo ago

', () => { value: { ...originalEnv, VITE_FEATURE_FLAG_GNOSIS: 'true' }, writable: true, }); + + // Mock the current date to December 22, 2025 for consistent relative time calculations + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-12-22T00:00:00Z')); }); afterEach(() => { @@ -103,6 +108,8 @@ describe('', () => { value: originalEnv, writable: true, }); + + vi.useRealTimers(); }); it('renders and matches snapshot', () => { diff --git a/src/apps/pillarx-app/components/TokensWithMarketDataTile/test/__snapshots__/TokensWithMarketDataTile.test.tsx.snap b/src/apps/pillarx-app/components/TokensWithMarketDataTile/test/__snapshots__/TokensWithMarketDataTile.test.tsx.snap index f56bc94c..1d83f607 100644 --- a/src/apps/pillarx-app/components/TokensWithMarketDataTile/test/__snapshots__/TokensWithMarketDataTile.test.tsx.snap +++ b/src/apps/pillarx-app/components/TokensWithMarketDataTile/test/__snapshots__/TokensWithMarketDataTile.test.tsx.snap @@ -97,7 +97,7 @@ exports[` > renders and matches snapshot 1`] = `

- 5mo ago + 8mo ago

> renders and matches snapshot 1`] = `

- 5mo ago + 8mo ago

> renders and matches snapshot 1`] = `

- 5mo ago + 8mo ago

> renders and matches snapshot 1`] = `

- 5mo ago + 8mo ago

> renders and matches snapshot 1`] = `

- 5mo ago + 8mo ago

> renders and matches snapshot 1`] = `

- 5mo ago + 8mo ago

> renders and matches snapshot 1`] = `

- 5mo ago + 8mo ago

> renders and matches snapshot 1`] = `

- 5mo ago + 8mo ago

> Rendering and Snapshot > renders correctly and matches + `; diff --git a/src/containers/Main.tsx b/src/containers/Main.tsx index 9fe11bff..5a1d9825 100644 --- a/src/containers/Main.tsx +++ b/src/containers/Main.tsx @@ -16,7 +16,7 @@ import { WalletClient, } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; -import { mainnet, sepolia } from 'viem/chains'; +import { mainnet, sepolia, polygon } from 'viem/chains'; import { createConfig, WagmiProvider, useAccount, useConnect } from 'wagmi'; import { walletConnect } from 'wagmi/connectors'; import * as Sentry from '@sentry/react'; @@ -851,7 +851,7 @@ const AuthLayout = () => { const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); export const config = createConfig({ - chains: [mainnet], + chains: [mainnet, polygon], connectors: [ walletConnect({ projectId: import.meta.env.VITE_REOWN_PROJECT_ID ?? '', @@ -867,6 +867,7 @@ export const config = createConfig({ ], transports: { [mainnet.id]: http(), + [polygon.id]: http(), }, }); diff --git a/vite.config.js b/vite.config.mjs similarity index 100% rename from vite.config.js rename to vite.config.mjs diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 00000000..5cd24b1f --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "name": "pillarx", + "compatibility_date": "2025-09-30", + "assets": { + "directory": "./build" + }, + "pages_build_output_dir": "./build" +} \ No newline at end of file