Compare commits
133
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b6a953933 | ||
|
|
33b0b6dcb4 | ||
|
|
cdc7d95db2 | ||
|
|
592bf6f1fd | ||
|
|
8973174ad5 | ||
|
|
c160e91c08 | ||
|
|
f33e31ad6e | ||
|
|
4111d0e638 | ||
|
|
ffb7b6487b | ||
|
|
77063e5c85 | ||
|
|
585eac2a6f | ||
|
|
c54db522b3 | ||
|
|
d62e9971ee | ||
|
|
7d796f5aa5 | ||
|
|
a5bb36141d | ||
|
|
f3234c3c41 | ||
|
|
91e3781e83 | ||
|
|
86e3aa8c04 | ||
|
|
aa1035d16c | ||
|
|
bd395a3cff | ||
|
|
446a69b1d1 | ||
|
|
826d9c80a8 | ||
|
|
772eb0cc53 | ||
|
|
5c6dd874e0 | ||
|
|
a9e47e4759 | ||
|
|
bfd728a9d1 | ||
|
|
381a2823d7 | ||
|
|
2111b8b12c | ||
|
|
1665e4d534 | ||
|
|
692cedf921 | ||
|
|
e1f4901b80 | ||
|
|
028f871d40 | ||
|
|
2821b4eeb7 | ||
|
|
adfa56b2a2 | ||
|
|
415ad90986 | ||
|
|
afcb7bba84 | ||
|
|
10c3b6f27f | ||
|
|
c64446306e | ||
|
|
2136c7ba4a | ||
|
|
67c3d861fe | ||
|
|
d2c12cc6ef | ||
|
|
c1b2e10f34 | ||
|
|
f48d8006bc | ||
|
|
20e323d666 | ||
|
|
0ec6142bf8 | ||
|
|
0ea25bb9ae | ||
|
|
67293fc269 | ||
|
|
cb77544a46 | ||
|
|
770e7b24da | ||
|
|
ff9df497c9 | ||
|
|
2d6c245b5e | ||
|
|
37b3d7fa07 | ||
|
|
8def4f33f6 | ||
|
|
d92ab6fabe | ||
|
|
198cfd0f95 | ||
|
|
9fba016cff | ||
|
|
4b8badba2e | ||
|
|
341325f844 | ||
|
|
7500d8723f | ||
|
|
fa45dea07d | ||
|
|
f94226ad7e | ||
|
|
0e159f776c | ||
|
|
f1a965b046 | ||
|
|
9f88b5c2be | ||
|
|
7e73c344f5 | ||
|
|
4bd4efce4b | ||
|
|
84f80437c1 | ||
|
|
ac48b2409b | ||
|
|
bde53fcf34 | ||
|
|
7437289140 | ||
|
|
ca1fb84eba | ||
|
|
087c29efdd | ||
|
|
9c2b0f7111 | ||
|
|
fd967ce8e3 | ||
|
|
7ab8808b50 | ||
|
|
ec75d9502c | ||
|
|
85c4ef10ce | ||
|
|
50e01ce067 | ||
|
|
f15f62feec | ||
|
|
2d1549bb8b | ||
|
|
effe60b48f | ||
|
|
2029869d9c | ||
|
|
02f6f87d08 | ||
|
|
1305527822 | ||
|
|
b882ad7807 | ||
|
|
030c522816 | ||
|
|
c356813852 | ||
|
|
1c638edd80 | ||
|
|
2fd5b84505 | ||
|
|
2bfc9b2eb2 | ||
|
|
e9dc2190a1 | ||
|
|
d0fa1b1e66 | ||
|
|
85a5037f50 | ||
|
|
fd67c5db58 | ||
|
|
ca65b43566 | ||
|
|
1f72396d7f | ||
|
|
08834f6385 | ||
|
|
143ab6d127 | ||
|
|
bc512551ab | ||
|
|
8e29d48426 | ||
|
|
e7e6446e7d | ||
|
|
7113f7c669 | ||
|
|
f9d7935d84 | ||
|
|
acd5656d4d | ||
|
|
6d1d344c48 | ||
|
|
26b8c320ae | ||
|
|
c7f7f48222 | ||
|
|
e814248d9c | ||
|
|
d7a0613067 | ||
|
|
dbc031664d | ||
|
|
5640d48854 | ||
|
|
9d6739ea59 | ||
|
|
dc49683357 | ||
|
|
30496388c4 | ||
|
|
c2ef5eb51c | ||
|
|
277c2e2ed9 | ||
|
|
0ac5e59eb9 | ||
|
|
fa5e13b5d3 | ||
|
|
45bb790dd1 | ||
|
|
102a8c8224 | ||
|
|
472e737616 | ||
|
|
0345492e99 | ||
|
|
e650434061 | ||
|
|
6712911950 | ||
|
|
1387337955 | ||
|
|
a2b737e144 | ||
|
|
c5f9921dc9 | ||
|
|
830125c920 | ||
|
|
af89836b6c | ||
|
|
d85ee5fac3 | ||
|
|
8bb4d4bc11 | ||
|
|
3c6668b6c8 | ||
|
|
6aa2765a34 |
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@a9763ed3d2388f5746f9dc3e1a55df7f4609bc89 # v2.5.1
|
||||
with:
|
||||
version: 2.0.4
|
||||
version: 2.0.6
|
||||
|
||||
- run: pnpm lint
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Package Management
|
||||
|
||||
- Use `pnpm install` (or `pnpm install --ignore-scripts` on Windows)
|
||||
- Monorepo managed with Turborepo and pnpm workspaces
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Build all packages
|
||||
pnpm build
|
||||
|
||||
# Build specific packages with dependencies
|
||||
turbo run build --filter=./packages/*
|
||||
|
||||
# Development with watch mode
|
||||
pnpm dev # Core thirdweb package
|
||||
pnpm dashboard # Run dashboard + dependencies
|
||||
pnpm playground # Run playground + dependencies
|
||||
pnpm portal # Run portal docs + dependencies
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Interactive testing (thirdweb package)
|
||||
cd packages/thirdweb && pnpm test:devr
|
||||
|
||||
# Test specific file
|
||||
pnpm test:dev <path-to-test-file>
|
||||
|
||||
# E2E testing (dashboard)
|
||||
cd apps/dashboard && pnpm playwright
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Lint all packages
|
||||
pnpm lint
|
||||
|
||||
# Auto-fix linting issues
|
||||
pnpm fix
|
||||
|
||||
# Format code
|
||||
turbo run format
|
||||
|
||||
# Biome is the primary linter/formatter
|
||||
```
|
||||
|
||||
### Development Workflow
|
||||
|
||||
```bash
|
||||
# Start development server for dashboard
|
||||
pnpm dashboard
|
||||
|
||||
# Start playground for SDK testing
|
||||
pnpm playground
|
||||
|
||||
# Generate changeset for releases
|
||||
pnpm changeset
|
||||
|
||||
# Version packages
|
||||
pnpm version-packages
|
||||
```
|
||||
|
||||
## Repository Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
This is a Turborepo monorepo with the main thirdweb v5 SDK consolidated into `/packages/thirdweb/`. Legacy packages are in `/legacy_packages/`.
|
||||
|
||||
### Core Package (`/packages/thirdweb/`)
|
||||
|
||||
**Main Modules:**
|
||||
|
||||
- `client/` - ThirdwebClient foundation
|
||||
- `chains/` - 50+ supported blockchain definitions
|
||||
- `contract/` - Contract interaction with automatic ABI resolution
|
||||
- `transaction/` - Transaction management and execution
|
||||
- `wallets/` - Comprehensive wallet integration system
|
||||
- `extensions/` - Modular contract extensions (ERC20, ERC721, etc.)
|
||||
- `auth/` - SIWE authentication and signature verification
|
||||
- `pay/` - Fiat and crypto payment infrastructure
|
||||
- `storage/` - IPFS integration for decentralized storage
|
||||
- `rpc/` - Low-level blockchain communication
|
||||
|
||||
**Exports Structure:**
|
||||
The SDK uses modular exports from `src/exports/` including:
|
||||
|
||||
- `thirdweb.ts` - Core client and utilities
|
||||
- `chains.ts` - Chain definitions
|
||||
- `wallets.ts` - Wallet connectors
|
||||
- `react.ts` - React hooks and components
|
||||
- `extensions/` - Contract standards and protocols
|
||||
|
||||
### Applications (`/apps/`)
|
||||
|
||||
- **dashboard** - Web-based developer console (Next.js, Chakra UI)
|
||||
- **playground-web** - Interactive SDK testing environment
|
||||
- **portal** - Documentation site with MDX
|
||||
- **nebula** - Account abstraction and smart wallet management
|
||||
- **wallet-ui** - Wallet interface and testing
|
||||
|
||||
### Key Packages (`/packages/`)
|
||||
|
||||
- **thirdweb** - Main SDK (TypeScript, React, React Native)
|
||||
- **engine** - thirdweb Engine API client
|
||||
- **insight** - Analytics and data APIs
|
||||
- **nebula** - Account abstraction client
|
||||
- **service-utils** - Shared utilities across services
|
||||
|
||||
## Development Practices
|
||||
|
||||
### GitHub Workflow & Pull Requests
|
||||
|
||||
- **PR titles**: Must start with affected workspace in brackets (e.g. `[SDK]`, `[Dashboard]`, `[Portal]`, `[Playground]`)
|
||||
- **PR descriptions**: Begin with one-sentence summary, add checklist of changes, reference issues with `Fixes #123`
|
||||
- **Commits**: Keep small and topical – one logical change per commit
|
||||
- **Branch naming**: Use `area/brief-topic` format (e.g. `sdk/fix-gas-estimate`). Avoid personal names
|
||||
- **Reviews**: Request at least one core maintainer review. Do not self-merge unless sole package owner
|
||||
- **CI requirements**: All checks (type-check, Biome, tests) must pass before merging
|
||||
|
||||
### Code Quality & Formatting
|
||||
|
||||
- **Biome**: Primary linter/formatter (rules in `biome.json`)
|
||||
- **Pre-commit**: Run `pnpm biome check --apply` before committing
|
||||
- **Build verification**: Run `pnpm build` after each file change to ensure everything builds
|
||||
- Avoid editor-specific configs; rely on shared settings
|
||||
|
||||
### TypeScript Guidelines
|
||||
|
||||
- **Style**: Write idiomatic TypeScript with explicit function declarations and return types
|
||||
- **File structure**: Limit each file to one stateless, single-responsibility function for clarity
|
||||
- **Types**: Re-use shared types from `@/types` or local `types.ts` barrels
|
||||
- **Interfaces vs Types**: Prefer type aliases over interface except for nominal shapes
|
||||
- **Type safety**: Avoid `any` and `unknown` unless unavoidable; narrow generics when possible
|
||||
- **Architecture**: Choose composition over inheritance; leverage utility types (`Partial`, `Pick`, etc.)
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
- **Co-location**: Place tests alongside code: `foo.ts` ↔ `foo.test.ts`
|
||||
- **Test approach**: Use real function invocations with stub data; avoid brittle mocks
|
||||
- **Network mocking**: Use Mock Service Worker (MSW) for fetch/HTTP call interception
|
||||
- **Test quality**: Keep tests deterministic and side-effect free
|
||||
- **Running tests**: `cd packages/thirdweb && pnpm test:dev <filename>`
|
||||
- **Test accounts**: Predefined accounts in `test/src/test-wallets.ts`
|
||||
- **Chain forking**: Use `FORKED_ETHEREUM_CHAIN` for mainnet interactions, `ANVIL_CHAIN` for isolated tests
|
||||
|
||||
### SDK Development (`packages/thirdweb`)
|
||||
|
||||
#### Public API Guidelines
|
||||
- **Exports**: Export everything via `exports/` directory, grouped by feature
|
||||
- **Documentation**: Every public symbol must have comprehensive TSDoc with:
|
||||
- At least one `@example` block that compiles
|
||||
- Custom annotation tags (`@beta`, `@internal`, `@experimental`)
|
||||
- **Comments**: Comment only ambiguous logic; avoid restating TypeScript in prose
|
||||
|
||||
#### Performance Optimization
|
||||
- **Lazy loading**: Load heavy dependencies inside async paths to keep initial bundle lean:
|
||||
```typescript
|
||||
const { jsPDF } = await import("jspdf");
|
||||
```
|
||||
- **Bundle budgets**: Track via `package.json#size-limit`
|
||||
- **Dependencies**: De-duplicate across packages through pnpm workspace hoisting
|
||||
|
||||
### Dashboard & Playground Development
|
||||
|
||||
#### UI Component Standards
|
||||
- **Core components**: Import primitives from `@/components/ui/*` (Button, Input, Select, Tabs, Card, Sidebar, Badge, Separator)
|
||||
- **Navigation**: Use `NavLink` for internal navigation with automatic active states
|
||||
- **Organization**: Group feature-specific components under `feature/components/*` with barrel `index.ts`
|
||||
|
||||
#### Styling Conventions
|
||||
- **CSS framework**: Tailwind CSS only – no inline styles or CSS modules
|
||||
- **Class merging**: Use `cn()` from `@/lib/utils` for conditional logic
|
||||
- **Design tokens**: Use design system tokens (backgrounds: `bg-card`, borders: `border-border`, muted text: `text-muted-foreground`)
|
||||
- **Component API**: Expose `className` prop on root element for overrides
|
||||
|
||||
#### Component Architecture
|
||||
- **Server Components** (Node edge):
|
||||
- Start files with `import "server-only";`
|
||||
- Read cookies/headers with `next/headers`
|
||||
- Access server-only environment variables
|
||||
- Perform heavy data fetching
|
||||
- Implement redirect logic with `redirect()` from `next/navigation`
|
||||
- **Client Components** (browser):
|
||||
- Begin files with `'use client';`
|
||||
- Handle interactive UI with React hooks (`useState`, `useEffect`, React Query, wallet hooks)
|
||||
- Access browser APIs (`localStorage`, `window`, `IntersectionObserver`)
|
||||
- Support fast transitions with prefetched data
|
||||
|
||||
#### Data Fetching Patterns
|
||||
- **Server Side**:
|
||||
- Always call `getAuthToken()` to retrieve JWT from cookies
|
||||
- Use `Authorization: Bearer` header – never embed tokens in URLs
|
||||
- Return typed results (`Project[]`, `User[]`) – avoid `any`
|
||||
- **Client Side**:
|
||||
- Wrap calls in React Query (`@tanstack/react-query`)
|
||||
- Use descriptive, stable `queryKeys` for cache hits
|
||||
- Configure `staleTime`/`cacheTime` based on freshness (default ≥ 60s)
|
||||
- Keep tokens secret via internal API routes or server actions
|
||||
|
||||
#### Analytics Event Guidelines
|
||||
- **When to add**: Only create events that answer clear product/business questions
|
||||
- **Check duplicates**: Review `src/@/analytics/report.ts` first
|
||||
- **Naming**:
|
||||
- Event name: human-readable `<subject> <verb>` (e.g. "contract deployed")
|
||||
- Function: `report<Subject><Verb>` (PascalCase)
|
||||
- **Template**:
|
||||
```typescript
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - Tracks number of contracts deployed
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @username
|
||||
*/
|
||||
export function reportContractDeployed(properties: {
|
||||
address: string;
|
||||
chainId: number;
|
||||
}) {
|
||||
posthog.capture("contract deployed", properties);
|
||||
}
|
||||
```
|
||||
- **Client-side only**: Never import `posthog-js` in server components
|
||||
- **Changes**: Inform **#eng-core-services** before renaming/removing events
|
||||
|
||||
### Extension Development
|
||||
|
||||
- Extensions follow modular pattern in `src/extensions/`
|
||||
- Auto-generated contracts from ABI definitions
|
||||
- Composable functions with TypeScript safety
|
||||
- Support for read/write operations
|
||||
|
||||
### Wallet Architecture
|
||||
|
||||
- Unified `Wallet` and `Account` interfaces
|
||||
- Support for in-app wallets (social/email login)
|
||||
- Smart wallets with account abstraction
|
||||
- EIP-1193, EIP-5792, EIP-7702 standard support
|
||||
|
||||
## Contribution Workflow
|
||||
|
||||
1. **Fork and Clone**: Create fork, clone, create feature branch
|
||||
2. **Install**: `pnpm install` (use `--ignore-scripts` on Windows)
|
||||
3. **Develop**: Use appropriate dev commands above
|
||||
4. **Test**: Write unit tests, run linting, test on demo apps
|
||||
5. **Changeset**: Run `pnpm changeset` for semantic versioning
|
||||
6. **PR**: Submit pull request to main branch
|
||||
|
||||
### Release Testing
|
||||
|
||||
Comment `/release-pr` on PR to trigger dev release to npm for testing.
|
||||
|
||||
### Changeset Guidelines
|
||||
|
||||
Each change in `packages/*` should contain a changeset for the appropriate package with the appropriate version bump:
|
||||
- **patch**: Changes that don't impact the public API
|
||||
- **minor**: Any new/modified public API
|
||||
- **major**: Breaking changes (surface prominently in PR descriptions)
|
||||
|
||||
### Documentation Standards
|
||||
|
||||
- For new UI components, add Storybook stories (`*.stories.tsx`) alongside the code
|
||||
- Surface breaking changes prominently in PR descriptions
|
||||
- Keep documentation focused on developer experience and practical usage
|
||||
|
||||
## Multi-Platform Support
|
||||
|
||||
The SDK supports:
|
||||
|
||||
- **Web**: React hooks and components
|
||||
- **React Native**: Mobile-specific exports and components
|
||||
- **Node.js**: Server-side functionality
|
||||
- **Framework Adapters**: Wagmi, Ethers compatibility layers
|
||||
|
||||
Key files:
|
||||
|
||||
- `src/exports/react.native.ts` - React Native specific exports
|
||||
- `packages/react-native-adapter/` - Mobile platform shims
|
||||
- `packages/wagmi-adapter/` - Wagmi ecosystem integration
|
||||
@@ -37,7 +37,7 @@ NEXT_PUBLIC_TYPESENSE_CONTRACT_API_KEY=
|
||||
|
||||
# posthog API key
|
||||
# - not required for prod/staging
|
||||
NEXT_PUBLIC_POSTHOG_API_KEY="ignored"
|
||||
NEXT_PUBLIC_POSTHOG_KEY=""
|
||||
|
||||
# Stripe Customer portal
|
||||
NEXT_PUBLIC_STRIPE_KEY=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
|
||||
"extends": "//"
|
||||
}
|
||||
|
||||
@@ -9,17 +9,20 @@ module.exports = [
|
||||
// -- build category
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/universal-bridge",
|
||||
"/payments",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
// -- end build category
|
||||
// -- storage
|
||||
|
||||
// -- scale category
|
||||
"/rpc",
|
||||
"/insight",
|
||||
"/storage",
|
||||
// -- end scale category
|
||||
|
||||
// -- nebula
|
||||
"/nebula",
|
||||
// --insight
|
||||
"/insight",
|
||||
// -- contracts
|
||||
"/contracts",
|
||||
"/contracts/modular-contracts",
|
||||
|
||||
@@ -208,35 +208,29 @@ function getConfig(): NextConfig {
|
||||
const withBundleAnalyzer = require("@next/bundle-analyzer")({
|
||||
enabled: process.env.ANALYZE === "true",
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { withPlausibleProxy } = require("next-plausible");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
return withBundleAnalyzer(
|
||||
withPlausibleProxy({
|
||||
customDomain: "https://pl.thirdweb.com",
|
||||
scriptName: "pl",
|
||||
})(
|
||||
withSentryConfig(
|
||||
{
|
||||
...baseNextConfig,
|
||||
// @ts-expect-error - this is a valid option
|
||||
webpack: (config) => {
|
||||
if (config.cache) {
|
||||
config.cache = Object.freeze({
|
||||
type: "memory",
|
||||
});
|
||||
}
|
||||
config.module = {
|
||||
...config.module,
|
||||
exprContextCritical: false,
|
||||
};
|
||||
// Important: return the modified config
|
||||
return config;
|
||||
},
|
||||
withSentryConfig(
|
||||
{
|
||||
...baseNextConfig,
|
||||
// @ts-expect-error - this is a valid option
|
||||
webpack: (config) => {
|
||||
if (config.cache) {
|
||||
config.cache = Object.freeze({
|
||||
type: "memory",
|
||||
});
|
||||
}
|
||||
config.module = {
|
||||
...config.module,
|
||||
exprContextCritical: false,
|
||||
};
|
||||
// Important: return the modified config
|
||||
return config;
|
||||
},
|
||||
SENTRY_OPTIONS,
|
||||
),
|
||||
},
|
||||
SENTRY_OPTIONS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+28
-27
@@ -4,13 +4,14 @@
|
||||
"@chakra-ui/styled-system": "^2.9.2",
|
||||
"@chakra-ui/theme-tools": "^2.1.2",
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.0",
|
||||
"@emotion/styled": "11.14.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.14",
|
||||
@@ -23,14 +24,14 @@
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@scalar/api-reference-react": "^0.6.19",
|
||||
"@sentry/nextjs": "9.29.0",
|
||||
"@scalar/api-reference-react": "0.7.25",
|
||||
"@sentry/nextjs": "9.34.0",
|
||||
"@shazow/whatsabi": "0.22.2",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@tanstack/react-query": "5.81.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@thirdweb-dev/vault-sdk": "workspace:*",
|
||||
"@vercel/functions": "2.1.0",
|
||||
"@vercel/functions": "2.2.2",
|
||||
"@vercel/og": "^0.6.8",
|
||||
"abitype": "1.0.8",
|
||||
"chakra-react-select": "^4.7.6",
|
||||
@@ -40,29 +41,29 @@
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"framer-motion": "12.17.0",
|
||||
"framer-motion": "12.23.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.6.1",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.514.0",
|
||||
"next": "15.3.3",
|
||||
"next-plausible": "^3.12.4",
|
||||
"lucide-react": "0.525.0",
|
||||
"next": "15.3.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
"nuqs": "^2.4.3",
|
||||
"p-limit": "^6.2.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.252.0",
|
||||
"prettier": "3.5.3",
|
||||
"posthog-js": "1.256.1",
|
||||
"posthog-node": "^5.4.0",
|
||||
"prettier": "3.6.2",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-error-boundary": "^5.0.0",
|
||||
"react-error-boundary": "6.0.0",
|
||||
"react-hook-form": "7.55.0",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-table": "^7.8.0",
|
||||
@@ -71,7 +72,7 @@
|
||||
"responsive-rsc": "0.0.7",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "2.0.5",
|
||||
"sonner": "2.0.6",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"stripe": "17.7.0",
|
||||
"swagger-ui-react": "^5.24.1",
|
||||
@@ -81,19 +82,19 @@
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.62"
|
||||
"zod": "3.25.75"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.4",
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "4.0.0",
|
||||
"@next/bundle-analyzer": "15.3.3",
|
||||
"@next/eslint-plugin-next": "15.3.3",
|
||||
"@playwright/test": "1.53.0",
|
||||
"@storybook/addon-docs": "9.0.8",
|
||||
"@storybook/addon-links": "9.0.8",
|
||||
"@storybook/addon-onboarding": "9.0.8",
|
||||
"@storybook/nextjs": "9.0.8",
|
||||
"@chromatic-com/storybook": "4.0.1",
|
||||
"@next/bundle-analyzer": "15.3.5",
|
||||
"@next/eslint-plugin-next": "15.3.5",
|
||||
"@playwright/test": "1.53.2",
|
||||
"@storybook/addon-docs": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/nextjs": "9.0.15",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.14.1",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
@@ -107,15 +108,15 @@
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"checkly": "5.6.0",
|
||||
"checkly": "6.0.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"eslint-plugin-storybook": "9.0.8",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"knip": "5.60.2",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.5",
|
||||
"storybook": "9.0.8",
|
||||
"postcss": "8.5.6",
|
||||
"storybook": "9.0.15",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
|
||||
@@ -106,9 +106,27 @@ const projectPageRedirects = [
|
||||
source: `${projectRoute}/nebula/:path*`,
|
||||
},
|
||||
{
|
||||
source: `${projectRoute}/connect/analytics`,
|
||||
destination: `${projectRoute}`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/connect/analytics`,
|
||||
},
|
||||
];
|
||||
|
||||
const teamPageRedirects = [
|
||||
{
|
||||
destination: "/team/:team_slug/~/billing/:path*",
|
||||
permanent: false,
|
||||
source: "/team/:team_slug/~/settings/billing/:path*",
|
||||
},
|
||||
{
|
||||
destination: "/team/:team_slug/~/billing/invoices/:path*",
|
||||
permanent: false,
|
||||
source: "/team/:team_slug/~/settings/invoices/:path*",
|
||||
},
|
||||
{
|
||||
destination: "/team/:team_slug/~/billing",
|
||||
permanent: false,
|
||||
source: "/team/:team_slug/~/settings/credits",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -309,13 +327,6 @@ async function redirects() {
|
||||
permanent: false,
|
||||
source: "/solutions/chains",
|
||||
},
|
||||
// redirect /rpc to portal
|
||||
{
|
||||
destination:
|
||||
"https://portal.thirdweb.com/infrastructure/rpc-edge/overview",
|
||||
permanent: false,
|
||||
source: "/rpc-edge",
|
||||
},
|
||||
// redirect /sdk to portal
|
||||
{
|
||||
destination: "https://portal.thirdweb.com/connect/blockchain-api",
|
||||
@@ -405,7 +416,7 @@ async function redirects() {
|
||||
source: "/connect/account-abstraction",
|
||||
},
|
||||
{
|
||||
destination: "/universal-bridge",
|
||||
destination: "/payments",
|
||||
permanent: false,
|
||||
source: "/connect/universal-bridge",
|
||||
},
|
||||
@@ -424,8 +435,19 @@ async function redirects() {
|
||||
permanent: false,
|
||||
source: "/engine",
|
||||
},
|
||||
{
|
||||
destination: "/rpc",
|
||||
permanent: false,
|
||||
source: "/rpc-edge",
|
||||
},
|
||||
{
|
||||
destination: "/payments",
|
||||
permanent: false,
|
||||
source: "/universal-bridge",
|
||||
},
|
||||
...legacyDashboardToTeamRedirects,
|
||||
...projectPageRedirects,
|
||||
...teamPageRedirects,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { ChainInfraSKU } from "@/types/billing";
|
||||
import { getAbsoluteUrl } from "@/utils/vercel";
|
||||
|
||||
export async function reSubscribePlan(options: {
|
||||
teamId: string;
|
||||
@@ -14,7 +17,10 @@ export async function reSubscribePlan(options: {
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/checkout/resubscribe-plan`,
|
||||
new URL(
|
||||
`/v1/teams/${options.teamId}/checkout/resubscribe-plan`,
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
),
|
||||
{
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
@@ -35,3 +41,83 @@ export async function reSubscribePlan(options: {
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getChainInfraCheckoutURL(options: {
|
||||
teamSlug: string;
|
||||
skus: ChainInfraSKU[];
|
||||
chainId: number;
|
||||
annual: boolean;
|
||||
}) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
error: "You are not logged in",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
new URL(
|
||||
`/v1/teams/${options.teamSlug}/checkout/create-link`,
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
),
|
||||
{
|
||||
body: JSON.stringify({
|
||||
annual: options.annual,
|
||||
baseUrl: getAbsoluteUrl(),
|
||||
chainId: options.chainId,
|
||||
skus: options.skus,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
console.error("Failed to create checkout link", text, res.status);
|
||||
switch (res.status) {
|
||||
case 402: {
|
||||
return {
|
||||
error:
|
||||
"You have outstanding invoices, please pay these first before re-subscribing.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
case 429: {
|
||||
return {
|
||||
error: "Too many requests, please try again later.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
case 403: {
|
||||
return {
|
||||
error: "You are not authorized to deploy infrastructure.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
error: "An unknown error occurred, please try again later.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
if (!json.result) {
|
||||
return {
|
||||
error: "An unknown error occurred, please try again later.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
data: json.result as string,
|
||||
status: "success",
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { PostHog } from "posthog-node";
|
||||
|
||||
let _posthogClient: PostHog | null = null;
|
||||
|
||||
function getPostHogServer(): PostHog | null {
|
||||
if (!_posthogClient && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
|
||||
_posthogClient = new PostHog(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
|
||||
host: "https://us.i.posthog.com",
|
||||
});
|
||||
}
|
||||
return _posthogClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled for a specific user
|
||||
* @param flagKey - The feature flag key
|
||||
* @param userEmail - The user's email address for filtering
|
||||
*/
|
||||
export const isFeatureFlagEnabled = unstable_cache(
|
||||
async (params: {
|
||||
flagKey: string;
|
||||
accountId: string;
|
||||
email: string | undefined;
|
||||
}): Promise<boolean> => {
|
||||
const posthogClient = getPostHogServer();
|
||||
if (!posthogClient) {
|
||||
console.warn("Posthog client not set");
|
||||
return true;
|
||||
}
|
||||
|
||||
const { flagKey, accountId, email } = params;
|
||||
|
||||
try {
|
||||
if (posthogClient && accountId) {
|
||||
const isEnabled = await posthogClient.isFeatureEnabled(
|
||||
flagKey,
|
||||
accountId,
|
||||
{
|
||||
personProperties: email ? { email } : undefined,
|
||||
},
|
||||
);
|
||||
if (isEnabled !== undefined) {
|
||||
return isEnabled;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error checking feature flag ${flagKey}:`, error);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
["is-feature-flag-enabled"],
|
||||
{
|
||||
revalidate: 3600, // 1 hour
|
||||
},
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
import type { ProductSKU } from "../types/billing";
|
||||
|
||||
// ----------------------------
|
||||
// CONTRACTS
|
||||
@@ -361,7 +362,11 @@ export function reportAssetCreationFailed(
|
||||
properties: { contractType: AssetContractType; error: string } & (
|
||||
| {
|
||||
assetType: "nft";
|
||||
step: "deploy-contract" | "mint-nfts" | "set-claim-conditions";
|
||||
step:
|
||||
| "deploy-contract"
|
||||
| "mint-nfts"
|
||||
| "set-claim-conditions"
|
||||
| "set-admins";
|
||||
}
|
||||
| {
|
||||
assetType: "coin";
|
||||
@@ -380,3 +385,31 @@ export function reportAssetCreationFailed(
|
||||
step: properties.step,
|
||||
});
|
||||
}
|
||||
|
||||
type UpsellParams = {
|
||||
content: "storage-limit";
|
||||
campaign: "create-coin" | "create-nft";
|
||||
sku: Exclude<ProductSKU, null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track how effective the upsells are in driving users to upgrade
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportUpsellShown(properties: UpsellParams) {
|
||||
posthog.capture("upsell shown", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track how effective the upsells are in driving users to upgrade
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportUpsellClicked(properties: UpsellParams) {
|
||||
posthog.capture("upsell clicked", properties);
|
||||
}
|
||||
|
||||
@@ -6,17 +6,54 @@ import type {
|
||||
EcosystemWalletStats,
|
||||
EngineCloudStats,
|
||||
InAppWalletStats,
|
||||
RpcMethodStats,
|
||||
TransactionStats,
|
||||
UniversalBridgeStats,
|
||||
UniversalBridgeWalletStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
WebhookLatencyStats,
|
||||
WebhookRequestStats,
|
||||
WebhookSummaryStats,
|
||||
} from "@/types/analytics";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
import { getChains } from "./chain";
|
||||
|
||||
export interface InsightChainStats {
|
||||
date: string;
|
||||
chainId: string;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface InsightStatusCodeStats {
|
||||
date: string;
|
||||
httpStatusCode: number;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface InsightEndpointStats {
|
||||
date: string;
|
||||
endpoint: string;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
interface InsightUsageStats {
|
||||
date: string;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface RpcMethodStats {
|
||||
date: string;
|
||||
evmMethod: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RpcUsageTypeStats {
|
||||
date: string;
|
||||
usageType: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
async function fetchAnalytics(
|
||||
input: string | URL,
|
||||
init?: RequestInit,
|
||||
@@ -76,6 +113,9 @@ function buildSearchParams(params: AnalyticsQueryParams): URLSearchParams {
|
||||
if (params.period) {
|
||||
searchParams.append("period", params.period);
|
||||
}
|
||||
if (params.limit) {
|
||||
searchParams.append("limit", params.limit.toString());
|
||||
}
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
@@ -225,6 +265,26 @@ export async function getRpcMethodUsage(
|
||||
return json.data as RpcMethodStats[];
|
||||
}
|
||||
|
||||
export async function getRpcUsageByType(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<RpcUsageTypeStats[]> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/rpc/usage-types?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
console.error("Failed to fetch RPC usage");
|
||||
return [];
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.data as RpcUsageTypeStats[];
|
||||
}
|
||||
|
||||
export async function getWalletUsers(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<WalletUserStats[]> {
|
||||
@@ -424,3 +484,145 @@ export async function getEngineCloudMethodUsage(
|
||||
const json = await res.json();
|
||||
return json.data as EngineCloudStats[];
|
||||
}
|
||||
|
||||
export async function getWebhookSummary(
|
||||
params: AnalyticsQueryParams & { webhookId: string },
|
||||
): Promise<{ data: WebhookSummaryStats[] } | { error: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
searchParams.append("webhookId", params.webhookId);
|
||||
|
||||
const res = await fetchAnalytics(
|
||||
`v2/webhook/summary?${searchParams.toString()}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
return { error: reason };
|
||||
}
|
||||
|
||||
return (await res.json()) as { data: WebhookSummaryStats[] };
|
||||
}
|
||||
|
||||
export async function getWebhookRequests(
|
||||
params: AnalyticsQueryParams & { webhookId?: string },
|
||||
): Promise<{ data: WebhookRequestStats[] } | { error: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
if (params.webhookId) {
|
||||
searchParams.append("webhookId", params.webhookId);
|
||||
}
|
||||
|
||||
const res = await fetchAnalytics(
|
||||
`v2/webhook/requests?${searchParams.toString()}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
return { error: reason };
|
||||
}
|
||||
|
||||
return (await res.json()) as { data: WebhookRequestStats[] };
|
||||
}
|
||||
|
||||
export async function getWebhookLatency(
|
||||
params: AnalyticsQueryParams & { webhookId?: string },
|
||||
): Promise<{ data: WebhookLatencyStats[] } | { error: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
if (params.webhookId) {
|
||||
searchParams.append("webhookId", params.webhookId);
|
||||
}
|
||||
const res = await fetchAnalytics(
|
||||
`v2/webhook/latency?${searchParams.toString()}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
return { error: reason };
|
||||
}
|
||||
|
||||
return (await res.json()) as { data: WebhookLatencyStats[] };
|
||||
}
|
||||
|
||||
export async function getInsightChainUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<{ data: InsightChainStats[] } | { errorMessage: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/insight/usage/by-chain?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
const errMsg = `Failed to fetch Insight chain usage: ${res?.status} - ${res.statusText} - ${reason}`;
|
||||
console.error(errMsg);
|
||||
return { errorMessage: errMsg };
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return { data: json.data as InsightChainStats[] };
|
||||
}
|
||||
|
||||
export async function getInsightStatusCodeUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<{ data: InsightStatusCodeStats[] } | { errorMessage: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/insight/usage/by-status-code?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
const errMsg = `Failed to fetch Insight status code usage: ${res?.status} - ${res.statusText} - ${reason}`;
|
||||
console.error(errMsg);
|
||||
return { errorMessage: errMsg };
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return { data: json.data as InsightStatusCodeStats[] };
|
||||
}
|
||||
|
||||
export async function getInsightEndpointUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<{ data: InsightEndpointStats[] } | { errorMessage: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/insight/usage/by-endpoint?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
const errMsg = `Failed to fetch Insight endpoint usage: ${res?.status} - ${res.statusText} - ${reason}`;
|
||||
console.error(errMsg);
|
||||
return { errorMessage: errMsg };
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return { data: json.data as InsightEndpointStats[] };
|
||||
}
|
||||
|
||||
export async function getInsightUsage(
|
||||
params: AnalyticsQueryParams,
|
||||
): Promise<{ data: InsightUsageStats[] } | { errorMessage: string }> {
|
||||
const searchParams = buildSearchParams(params);
|
||||
const res = await fetchAnalytics(
|
||||
`v2/insight/usage?${searchParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
const reason = await res?.text();
|
||||
const errMsg = `Failed to fetch Insight usage: ${res?.status} - ${res.statusText} - ${reason}`;
|
||||
console.error(errMsg);
|
||||
return { errorMessage: errMsg };
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return { data: json.data as InsightUsageStats[] };
|
||||
}
|
||||
|
||||
@@ -1,44 +1,8 @@
|
||||
import "server-only";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import type { ChainService } from "@/types/chain";
|
||||
|
||||
export async function getGasSponsoredChains() {
|
||||
if (!API_SERVER_SECRET) {
|
||||
throw new Error("API_SERVER_SECRET is not set");
|
||||
}
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains/gas-sponsored`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-service-api-key": API_SERVER_SECRET,
|
||||
},
|
||||
next: {
|
||||
revalidate: 15 * 60, //15 minutes
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
"Failed to fetch gas sponsored chains",
|
||||
res.status,
|
||||
res.statusText,
|
||||
);
|
||||
res.body?.cancel();
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return (await res.json()).data as number[];
|
||||
} catch (e) {
|
||||
console.error("Failed to parse gas sponsored chains", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getChains() {
|
||||
return fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/chains`,
|
||||
|
||||
+72
-20
@@ -1,22 +1,26 @@
|
||||
export const authOptions = [
|
||||
"email",
|
||||
"phone",
|
||||
"passkey",
|
||||
"siwe",
|
||||
"guest",
|
||||
"google",
|
||||
"facebook",
|
||||
"x",
|
||||
"discord",
|
||||
"farcaster",
|
||||
"telegram",
|
||||
"github",
|
||||
"twitch",
|
||||
"steam",
|
||||
"apple",
|
||||
"coinbase",
|
||||
"line",
|
||||
] as const;
|
||||
import "server-only";
|
||||
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type AuthOption =
|
||||
| "email"
|
||||
| "phone"
|
||||
| "passkey"
|
||||
| "siwe"
|
||||
| "guest"
|
||||
| "google"
|
||||
| "facebook"
|
||||
| "x"
|
||||
| "discord"
|
||||
| "farcaster"
|
||||
| "telegram"
|
||||
| "github"
|
||||
| "twitch"
|
||||
| "steam"
|
||||
| "apple"
|
||||
| "coinbase"
|
||||
| "line";
|
||||
|
||||
export type Ecosystem = {
|
||||
name: string;
|
||||
@@ -24,7 +28,7 @@ export type Ecosystem = {
|
||||
id: string;
|
||||
slug: string;
|
||||
permission: "PARTNER_WHITELIST" | "ANYONE";
|
||||
authOptions: (typeof authOptions)[number][];
|
||||
authOptions: AuthOption[];
|
||||
customAuthOptions?: {
|
||||
authEndpoint?: {
|
||||
url: string;
|
||||
@@ -47,6 +51,54 @@ export type Ecosystem = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function fetchEcosystemList(teamIdOrSlug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await res.json()).result as Ecosystem[];
|
||||
}
|
||||
|
||||
export async function fetchEcosystem(slug: string, teamIdOrSlug: string) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(data);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as { result: Ecosystem };
|
||||
return data.result;
|
||||
}
|
||||
|
||||
type PartnerPermission = "PROMPT_USER_V1" | "FULL_CONTROL_V1";
|
||||
export type Partner = {
|
||||
id: string;
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import { getAddress } from "thirdweb";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export type ProjectContract = {
|
||||
@@ -43,3 +44,43 @@ export async function getProjectContracts(options: {
|
||||
|
||||
return data.result;
|
||||
}
|
||||
|
||||
export type PartialProject = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* get a list of projects within a team that have a given contract imported
|
||||
*/
|
||||
export async function getContractImportedProjects(options: {
|
||||
teamId: string;
|
||||
authToken: string;
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
const url = new URL(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/projects/contracts/lookup?chainId=${options.chainId}&contractAddress=${getAddress(options.contractAddress)}`,
|
||||
);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${options.authToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await res.text();
|
||||
console.error("Error fetching: /projects/contracts/lookup");
|
||||
console.error(errorMessage);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
result: PartialProject[];
|
||||
};
|
||||
|
||||
return data.result;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { ProductSKU } from "@/types/billing";
|
||||
import type { ChainInfraSKU, ProductSKU } from "@/types/billing";
|
||||
|
||||
type InvoiceLine = {
|
||||
// amount for this line item
|
||||
@@ -22,7 +22,7 @@ type Invoice = {
|
||||
|
||||
export type TeamSubscription = {
|
||||
id: string;
|
||||
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT";
|
||||
type: "PLAN" | "USAGE" | "PLAN_ADD_ON" | "PRODUCT" | "CHAIN";
|
||||
status:
|
||||
| "incomplete"
|
||||
| "incomplete_expired"
|
||||
@@ -37,6 +37,13 @@ export type TeamSubscription = {
|
||||
trialStart: string | null;
|
||||
trialEnd: string | null;
|
||||
upcomingInvoice: Invoice;
|
||||
skus: (ProductSKU | ChainInfraSKU)[];
|
||||
};
|
||||
|
||||
type ChainTeamSubscription = Omit<TeamSubscription, "skus"> & {
|
||||
chainId: string;
|
||||
skus: ChainInfraSKU[];
|
||||
isLegacy: boolean;
|
||||
};
|
||||
|
||||
export async function getTeamSubscriptions(slug: string) {
|
||||
@@ -60,3 +67,61 @@ export async function getTeamSubscriptions(slug: string) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const CHAIN_PLAN_TO_INFRA = {
|
||||
"chain:plan:gold": ["chain:infra:rpc", "chain:infra:account_abstraction"],
|
||||
"chain:plan:platinum": [
|
||||
"chain:infra:rpc",
|
||||
"chain:infra:insight",
|
||||
"chain:infra:account_abstraction",
|
||||
],
|
||||
"chain:plan:ultimate": [
|
||||
"chain:infra:rpc",
|
||||
"chain:infra:insight",
|
||||
"chain:infra:account_abstraction",
|
||||
],
|
||||
};
|
||||
|
||||
export async function getChainSubscriptions(slug: string) {
|
||||
const allSubscriptions = await getTeamSubscriptions(slug);
|
||||
if (!allSubscriptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// first replace any sku that MIGHT match a chain plan
|
||||
const updatedSubscriptions = allSubscriptions
|
||||
.filter((s) => s.type === "CHAIN")
|
||||
.map((s) => {
|
||||
const skus = s.skus;
|
||||
const updatedSkus = skus.flatMap((sku) => {
|
||||
const plan =
|
||||
CHAIN_PLAN_TO_INFRA[sku as keyof typeof CHAIN_PLAN_TO_INFRA];
|
||||
return plan ? plan : sku;
|
||||
});
|
||||
return {
|
||||
...s,
|
||||
isLegacy: updatedSkus.length !== skus.length,
|
||||
skus: updatedSkus,
|
||||
};
|
||||
});
|
||||
|
||||
return updatedSubscriptions.filter(
|
||||
(s): s is ChainTeamSubscription =>
|
||||
"chainId" in s && typeof s.chainId === "string",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getChainSubscriptionForChain(
|
||||
slug: string,
|
||||
chainId: number,
|
||||
) {
|
||||
const chainSubscriptions = await getChainSubscriptions(slug);
|
||||
|
||||
if (!chainSubscriptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
chainSubscriptions.find((s) => s.chainId === chainId.toString()) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export interface WebhookConfig {
|
||||
id: string;
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
destinationUrl: string;
|
||||
description: string;
|
||||
pausedAt: string | null;
|
||||
webhookSecret: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
topics: {
|
||||
id: string;
|
||||
serviceName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
filters: object | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
type WebhookConfigsResponse =
|
||||
| {
|
||||
data: WebhookConfig[];
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
interface CreateWebhookConfigRequest {
|
||||
topics: { id: string; filters: object | null }[];
|
||||
destinationUrl: string;
|
||||
description: string;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
type CreateWebhookConfigResponse =
|
||||
| {
|
||||
data: WebhookConfig;
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
export interface Topic {
|
||||
id: string;
|
||||
serviceName: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
type TopicsResponse =
|
||||
| {
|
||||
data: Topic[];
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
interface UpdateWebhookConfigRequest {
|
||||
destinationUrl?: string;
|
||||
topics?: { id: string; filters: object | null }[];
|
||||
description?: string;
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
type UpdateWebhookConfigResponse =
|
||||
| {
|
||||
data: WebhookConfig;
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
type DeleteWebhookConfigResponse =
|
||||
| {
|
||||
data: WebhookConfig;
|
||||
status: "success";
|
||||
}
|
||||
| {
|
||||
body: string;
|
||||
reason: string;
|
||||
status: "error";
|
||||
};
|
||||
|
||||
export async function getWebhookConfigs(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
}): Promise<WebhookConfigsResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWebhookConfig(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
config: CreateWebhookConfigRequest;
|
||||
}): Promise<CreateWebhookConfigResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs`,
|
||||
{
|
||||
body: JSON.stringify(props.config),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAvailableTopics(): Promise<TopicsResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/webhook-topics`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateWebhookConfig(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
webhookConfigId: string;
|
||||
config: UpdateWebhookConfigRequest;
|
||||
}): Promise<UpdateWebhookConfigResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
|
||||
{
|
||||
body: JSON.stringify(props.config),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PATCH",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteWebhookConfig(props: {
|
||||
teamIdOrSlug: string;
|
||||
projectIdOrSlug: string;
|
||||
webhookConfigId: string;
|
||||
}): Promise<DeleteWebhookConfigResponse> {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
if (!authToken) {
|
||||
return {
|
||||
body: "Authentication required",
|
||||
reason: "no_auth_token",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${props.teamIdOrSlug}/projects/${props.projectIdOrSlug}/webhook-configs/${props.webhookConfigId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
data: result.data,
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { normalizeTime } from "@/lib/time";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DateRangeSelector(props: {
|
||||
range: Range;
|
||||
setRange: (range: Range) => void;
|
||||
popoverAlign?: "start" | "end" | "center";
|
||||
className?: string;
|
||||
}) {
|
||||
const { range, setRange } = props;
|
||||
const daysDiff = differenceInCalendarDays(range.to, range.from);
|
||||
@@ -26,7 +28,7 @@ export function DateRangeSelector(props: {
|
||||
|
||||
return (
|
||||
<DatePickerWithRange
|
||||
className="w-auto bg-card"
|
||||
className={cn("w-auto bg-card", props.className)}
|
||||
from={range.from}
|
||||
header={
|
||||
<div className="mb-2 border-border border-b p-4">
|
||||
|
||||
@@ -76,8 +76,9 @@ export function RangeSelector({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-4">
|
||||
<div className="flex justify-end gap-3 flex-col lg:flex-row">
|
||||
<DateRangeSelector
|
||||
className="rounded-full"
|
||||
range={localRange}
|
||||
setRange={(newRange) => {
|
||||
setRange(newRange);
|
||||
@@ -89,7 +90,7 @@ export function RangeSelector({
|
||||
}}
|
||||
/>
|
||||
<IntervalSelector
|
||||
className="bg-card"
|
||||
className="bg-card rounded-full"
|
||||
intervalType={localInterval}
|
||||
setIntervalType={(newInterval) => {
|
||||
setInterval(newInterval);
|
||||
|
||||
@@ -82,7 +82,7 @@ function UnpaidInvoicesWarning({ teamSlug }: { teamSlug: string }) {
|
||||
|
||||
<Button asChild className="w-full gap-2 bg-card" variant="outline">
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
|
||||
href={`/team/${teamSlug}/~/billing/invoices?status=open`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -1,45 +1,18 @@
|
||||
import { formatDistance } from "date-fns";
|
||||
import { CircleAlertIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Account, BillingCredit } from "@/hooks/useApi";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { ChainIconClient } from "@/icons/ChainIcon";
|
||||
import type { BillingCredit } from "@/hooks/useApi";
|
||||
import { formatToDollars } from "./formatToDollars";
|
||||
|
||||
interface CreditsItemProps {
|
||||
credit?: BillingCredit;
|
||||
isOpCreditDefault?: boolean;
|
||||
onClickApply?: () => void;
|
||||
twAccount: Account;
|
||||
client: ThirdwebClient;
|
||||
teamSlug: string;
|
||||
}
|
||||
|
||||
export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
credit,
|
||||
isOpCreditDefault,
|
||||
onClickApply,
|
||||
twAccount,
|
||||
client,
|
||||
teamSlug,
|
||||
}) => {
|
||||
const [hasAppliedForOpGrant] = useLocalStorage(
|
||||
`appliedForOpGrant-${twAccount.id}`,
|
||||
false,
|
||||
);
|
||||
|
||||
const isOpCredit = credit?.name.startsWith("OP -") || isOpCreditDefault;
|
||||
export const CreditsItem: React.FC<CreditsItemProps> = ({ credit }) => {
|
||||
const isTwCredit = credit?.name.startsWith("TW -");
|
||||
const isStartupCredit = credit?.name.startsWith("SU -");
|
||||
|
||||
let creditTitle = credit?.name ?? "thirdweb credits";
|
||||
if (isOpCredit) {
|
||||
creditTitle = "Optimism sponsorship credits";
|
||||
} else if (isTwCredit) {
|
||||
if (isTwCredit) {
|
||||
creditTitle = "thirdweb credits";
|
||||
} else if (isStartupCredit) {
|
||||
creditTitle = "Startup grant credits";
|
||||
@@ -50,13 +23,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
<div className="flex flex-col gap-3 p-4 lg:p-6">
|
||||
<div className="relative">
|
||||
<div className="absolute top-0 right-0">
|
||||
{isOpCredit ? (
|
||||
<ChainIconClient
|
||||
className="size-6"
|
||||
client={client}
|
||||
src="ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/optimism/512.png"
|
||||
/>
|
||||
) : isTwCredit ? (
|
||||
{isTwCredit ? (
|
||||
<Image
|
||||
alt="tw-credit"
|
||||
className="size-6"
|
||||
@@ -112,35 +79,7 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasAppliedForOpGrant && !credit && isOpCredit && (
|
||||
<Alert variant="info">
|
||||
<CircleAlertIcon className="size-5" />
|
||||
<AlertTitle>Grant application pending approval</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will receive an email once your application's status
|
||||
changes.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasAppliedForOpGrant && isOpCredit && (
|
||||
<div className="mt-2 flex justify-end border-t px-4 py-4 lg:px-6">
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/credits`}
|
||||
onClick={() => {
|
||||
if (onClickApply) {
|
||||
onClickApply();
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply Now
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,41 +2,26 @@
|
||||
|
||||
// TODO - convert to RSC
|
||||
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { type Account, useAccountCredits } from "@/hooks/useApi";
|
||||
import { useAccountCredits } from "@/hooks/useApi";
|
||||
import { CreditsItem } from "./CreditsItem";
|
||||
|
||||
export const CreditsInfoCard = (props: {
|
||||
twAccount: Account;
|
||||
client: ThirdwebClient;
|
||||
teamSlug: string;
|
||||
}) => {
|
||||
export const CreditsInfoCard = () => {
|
||||
const { data: credits } = useAccountCredits();
|
||||
|
||||
if (!credits) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opCredit = credits.find((crd) => crd.name.startsWith("OP -"));
|
||||
const restCredits = credits.filter((crd) => !crd.name.startsWith("OP -"));
|
||||
|
||||
if (restCredits.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<CreditsItem
|
||||
client={props.client}
|
||||
credit={opCredit}
|
||||
isOpCreditDefault={true}
|
||||
teamSlug={props.teamSlug}
|
||||
twAccount={props.twAccount}
|
||||
/>
|
||||
{restCredits?.map((credit) => (
|
||||
<CreditsItem
|
||||
client={props.client}
|
||||
credit={credit}
|
||||
key={credit.couponId}
|
||||
teamSlug={props.teamSlug}
|
||||
twAccount={props.twAccount}
|
||||
/>
|
||||
{restCredits.map((credit) => (
|
||||
<CreditsItem credit={credit} key={credit.couponId} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -60,7 +60,7 @@ function BillingWarning({ teamSlug }: { teamSlug: string }) {
|
||||
You have outstanding invoices. Please{" "}
|
||||
<Link
|
||||
className="font-medium text-amber-700 underline transition-colors hover:text-amber-900 dark:text-amber-400 dark:hover:text-amber-300"
|
||||
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
|
||||
href={`/team/${teamSlug}/~/billing/invoices?status=open`}
|
||||
>
|
||||
pay them
|
||||
</Link>{" "}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button asChild className="justify-start gap-2" size="sm">
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true&highlight=${props.requiredPlan}`}
|
||||
href={`/team/${props.teamSlug}/~/billing?showPlans=true&highlight=${props.requiredPlan}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function MobileSidebar(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function useActiveSidebarLink(links: SidebarLink[]) {
|
||||
function useActiveSidebarLink(links: SidebarLink[]) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeLink = useMemo(() => {
|
||||
|
||||
@@ -152,6 +152,7 @@ export function SingleNetworkSelector(props: {
|
||||
disableChainId?: boolean;
|
||||
align?: "center" | "start" | "end";
|
||||
disableTestnets?: boolean;
|
||||
disableDeprecated?: boolean;
|
||||
placeholder?: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
@@ -169,8 +170,17 @@ export function SingleNetworkSelector(props: {
|
||||
chains = chains.filter((chain) => chainIdSet.has(chain.chainId));
|
||||
}
|
||||
|
||||
if (props.disableDeprecated) {
|
||||
chains = chains.filter((chain) => chain.status !== "deprecated");
|
||||
}
|
||||
|
||||
return chains;
|
||||
}, [allChains, props.chainIds, props.disableTestnets]);
|
||||
}, [
|
||||
allChains,
|
||||
props.chainIds,
|
||||
props.disableTestnets,
|
||||
props.disableDeprecated,
|
||||
]);
|
||||
|
||||
const options = useMemo(() => {
|
||||
return chainsToShow.map((chain) => {
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
"use client";
|
||||
|
||||
import { cn } from "../../lib/utils";
|
||||
import { AppFooter } from "../footers/app-footer";
|
||||
import { NavLink } from "../ui/NavLink";
|
||||
import { Separator } from "../ui/separator";
|
||||
import { MobileSidebar, useActiveSidebarLink } from "./MobileSidebar";
|
||||
import { MobileSidebar } from "./MobileSidebar";
|
||||
import { CustomSidebar, type SidebarLink } from "./Sidebar";
|
||||
|
||||
export function SidebarLayout(props: {
|
||||
@@ -51,129 +34,3 @@ export function SidebarLayout(props: {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullWidthSidebarLayout(props: {
|
||||
contentSidebarLinks: SidebarLink[];
|
||||
footerSidebarLinks?: SidebarLink[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { contentSidebarLinks, children, footerSidebarLinks } = props;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 overflow-y-hidden",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{/* left - sidebar */}
|
||||
<Sidebar className="pt-2" collapsible="icon" side="left">
|
||||
<SidebarContent className="p-2">
|
||||
<RenderSidebarMenu links={contentSidebarLinks} />
|
||||
</SidebarContent>
|
||||
|
||||
{footerSidebarLinks && (
|
||||
<SidebarFooter className="pb-3">
|
||||
<RenderSidebarMenu links={footerSidebarLinks} />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
{/* right - content */}
|
||||
<div className="flex h-full flex-grow flex-col overflow-y-auto">
|
||||
<MobileSidebarTrigger
|
||||
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 grow flex-col max-sm:w-full">
|
||||
{children}
|
||||
</main>
|
||||
<AppFooter containerClassName="max-w-7xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSidebarGroup(props: {
|
||||
sidebarLinks: SidebarLink[];
|
||||
groupName: string;
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarMenuItem>
|
||||
<SidebarGroupLabel> {props.groupName}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<RenderSidebarMenu links={props.sidebarLinks} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarMenuItem>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSidebarMenu(props: { links: SidebarLink[] }) {
|
||||
const sidebar = useSidebar();
|
||||
return (
|
||||
<SidebarMenu className="gap-1.5">
|
||||
{props.links.map((link, idx) => {
|
||||
// link
|
||||
if ("href" in link) {
|
||||
return (
|
||||
<SidebarMenuSubItem key={link.href}>
|
||||
<SidebarMenuButton asChild>
|
||||
<NavLink
|
||||
activeClassName="text-foreground bg-accent"
|
||||
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent"
|
||||
exactMatch={link.exactMatch}
|
||||
href={link.href}
|
||||
isActive={link.isActive}
|
||||
onClick={() => {
|
||||
sidebar.setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
{link.icon && <link.icon className="size-4" />}
|
||||
<span>{link.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
}
|
||||
|
||||
// separator
|
||||
if ("separator" in link) {
|
||||
return (
|
||||
<SidebarSeparator
|
||||
className="my-1"
|
||||
key={`separator-${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine here
|
||||
idx
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// group
|
||||
return (
|
||||
<RenderSidebarGroup
|
||||
groupName={link.group}
|
||||
key={link.group}
|
||||
sidebarLinks={link.links}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSidebarTrigger(props: { links: SidebarLink[] }) {
|
||||
const activeLink = useActiveSidebarLink(props.links);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b px-4 py-4 lg:hidden">
|
||||
<SidebarTrigger className="size-4" />
|
||||
<Separator className="h-4" orientation="vertical" />
|
||||
{activeLink && <span className="text-sm">{activeLink.label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -164,7 +165,6 @@ export function TWTable<TRowData>(tableProps: TWTableProps<TRowData>) {
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
// biome-ignore lint/a11y/useSemanticElements: FIXME
|
||||
role="group"
|
||||
{...(tableProps.onRowClick
|
||||
? {
|
||||
|
||||
@@ -45,7 +45,7 @@ export function TeamPlanBadge(props: {
|
||||
}
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
router.push(`/team/${props.teamSlug}/~/settings/billing?showPlans=true`);
|
||||
router.push(`/team/${props.teamSlug}/~/billing?showPlans=true`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -38,12 +38,18 @@ const ACCENT = {
|
||||
type UpsellBannerCardProps = {
|
||||
title: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
cta: {
|
||||
text: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
target?: "_blank";
|
||||
link: string;
|
||||
};
|
||||
cta?:
|
||||
| {
|
||||
text: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
target?: "_blank";
|
||||
link: string;
|
||||
}
|
||||
| {
|
||||
text: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
accentColor?: keyof typeof ACCENT;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
@@ -93,25 +99,41 @@ export function UpsellBannerCard(props: UpsellBannerCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
|
||||
color.btn,
|
||||
)}
|
||||
size="sm"
|
||||
>
|
||||
<Link
|
||||
href={props.cta.link}
|
||||
rel={
|
||||
props.cta.target === "_blank" ? "noopener noreferrer" : undefined
|
||||
}
|
||||
target={props.cta.target}
|
||||
{props.cta && "target" in props.cta ? (
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
|
||||
color.btn,
|
||||
)}
|
||||
size="sm"
|
||||
>
|
||||
<Link
|
||||
href={props.cta.link}
|
||||
rel={
|
||||
props.cta.target === "_blank"
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
target={props.cta.target}
|
||||
>
|
||||
{props.cta.text}
|
||||
{props.cta.icon && <span className="ml-2">{props.cta.icon}</span>}
|
||||
</Link>
|
||||
</Button>
|
||||
) : props.cta && "onClick" in props.cta ? (
|
||||
<Button
|
||||
className={cn(
|
||||
"mt-2 gap-2 hover:translate-y-0 hover:shadow-inner sm:mt-0",
|
||||
color.btn,
|
||||
)}
|
||||
onClick={props.cta.onClick}
|
||||
size="sm"
|
||||
>
|
||||
{props.cta.text}
|
||||
{props.cta.icon && <span className="ml-2">{props.cta.icon}</span>}
|
||||
</Link>
|
||||
</Button>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,7 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
title: string;
|
||||
description?: string;
|
||||
titleClassName?: string;
|
||||
headerClassName?: string;
|
||||
};
|
||||
customHeader?: React.ReactNode;
|
||||
// chart config
|
||||
@@ -52,6 +53,12 @@ type ThirdwebAreaChartProps<TConfig extends ChartConfig> = {
|
||||
toolTipLabelFormatter?: (label: string, payload: unknown) => React.ReactNode;
|
||||
toolTipValueFormatter?: (value: unknown) => React.ReactNode;
|
||||
emptyChartState?: React.ReactElement;
|
||||
margin?: {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
@@ -62,7 +69,7 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
return (
|
||||
<Card className={props.className}>
|
||||
{props.header && (
|
||||
<CardHeader>
|
||||
<CardHeader className={props.header.headerClassName}>
|
||||
<CardTitle className={cn("mb-2", props.header.titleClassName)}>
|
||||
{props.header.title}
|
||||
</CardTitle>
|
||||
@@ -85,7 +92,16 @@ export function ThirdwebAreaChart<TConfig extends ChartConfig>(
|
||||
{props.emptyChartState}
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<AreaChart accessibilityLayer data={props.data}>
|
||||
<AreaChart
|
||||
accessibilityLayer
|
||||
data={props.data}
|
||||
margin={{
|
||||
right: props.margin?.right ?? 0,
|
||||
left: props.margin?.left ?? 0,
|
||||
bottom: props.margin?.bottom ?? 10,
|
||||
top: props.margin?.top ?? 0,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{props.yAxis && <YAxis axisLine={false} tickLine={false} />}
|
||||
<XAxis
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { AppFooter } from "@/components/footers/app-footer";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { DynamicHeight } from "@/components/ui/DynamicHeight";
|
||||
import { NavLink } from "@/components/ui/NavLink";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ShadcnSidebarBaseLink = {
|
||||
href: string;
|
||||
label: React.ReactNode;
|
||||
exactMatch?: boolean;
|
||||
icon?: React.FC<{ className?: string }>;
|
||||
isActive?: (pathname: string) => boolean;
|
||||
};
|
||||
|
||||
type ShadcnSidebarLink =
|
||||
| ShadcnSidebarBaseLink
|
||||
| {
|
||||
group: string;
|
||||
links: ShadcnSidebarBaseLink[];
|
||||
}
|
||||
| {
|
||||
separator: true;
|
||||
}
|
||||
| {
|
||||
subMenu: Omit<ShadcnSidebarBaseLink, "href" | "exactMatch">;
|
||||
links: Omit<ShadcnSidebarBaseLink, "icon">[];
|
||||
};
|
||||
|
||||
export function FullWidthSidebarLayout(props: {
|
||||
contentSidebarLinks: ShadcnSidebarLink[];
|
||||
footerSidebarLinks?: ShadcnSidebarLink[];
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const { contentSidebarLinks, children, footerSidebarLinks } = props;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full flex-1 overflow-y-hidden",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{/* left - sidebar */}
|
||||
<Sidebar className="pt-2" collapsible="icon" side="left">
|
||||
<SidebarContent className="p-2">
|
||||
<RenderSidebarMenu links={contentSidebarLinks} />
|
||||
</SidebarContent>
|
||||
|
||||
{footerSidebarLinks && (
|
||||
<SidebarFooter className="pb-3">
|
||||
<RenderSidebarMenu links={footerSidebarLinks} />
|
||||
</SidebarFooter>
|
||||
)}
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
|
||||
{/* right - content */}
|
||||
<div className="flex h-full flex-grow flex-col overflow-y-auto">
|
||||
<MobileSidebarTrigger
|
||||
links={[...contentSidebarLinks, ...(footerSidebarLinks || [])]}
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 grow flex-col max-sm:w-full">
|
||||
{children}
|
||||
</main>
|
||||
<AppFooter containerClassName="max-w-7xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileSidebarTrigger(props: { links: ShadcnSidebarLink[] }) {
|
||||
const activeLink = useActiveShadcnSidebarLink(props.links);
|
||||
const parentSubNav = props.links.find(
|
||||
(link) =>
|
||||
"subMenu" in link && link.links.some((l) => l.href === activeLink?.href),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-b px-4 py-4 lg:hidden">
|
||||
<SidebarTrigger className="size-4" />
|
||||
<Separator
|
||||
className="h-4 bg-muted-foreground/50"
|
||||
orientation="vertical"
|
||||
/>
|
||||
{parentSubNav && "subMenu" in parentSubNav && (
|
||||
<>
|
||||
<span className="text-sm">{parentSubNav.subMenu.label}</span>
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground/50 -mx-1.5" />
|
||||
</>
|
||||
)}
|
||||
{activeLink && <span className="text-sm">{activeLink.label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useActiveShadcnSidebarLink(links: ShadcnSidebarLink[]) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const activeLink = useMemo(() => {
|
||||
function isActive(link: ShadcnSidebarBaseLink) {
|
||||
if (link.exactMatch) {
|
||||
return link.href === pathname;
|
||||
}
|
||||
return pathname?.startsWith(link.href);
|
||||
}
|
||||
|
||||
for (const link of links) {
|
||||
if ("links" in link) {
|
||||
for (const subLink of link.links) {
|
||||
if (isActive(subLink)) {
|
||||
return subLink;
|
||||
}
|
||||
}
|
||||
} else if ("href" in link) {
|
||||
if (isActive(link)) {
|
||||
return link;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [links, pathname]);
|
||||
|
||||
return activeLink;
|
||||
}
|
||||
|
||||
function useIsSubnavActive(links: ShadcnSidebarBaseLink[]) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isSubnavActive = useMemo(() => {
|
||||
function isActive(link: ShadcnSidebarBaseLink) {
|
||||
if (link.exactMatch) {
|
||||
return link.href === pathname;
|
||||
}
|
||||
return pathname?.startsWith(link.href);
|
||||
}
|
||||
|
||||
return links.some(isActive);
|
||||
}, [links, pathname]);
|
||||
|
||||
return isSubnavActive;
|
||||
}
|
||||
|
||||
function RenderSidebarGroup(props: {
|
||||
sidebarLinks: ShadcnSidebarLink[];
|
||||
groupName: string;
|
||||
}) {
|
||||
return (
|
||||
<SidebarGroup className="p-0">
|
||||
<SidebarMenuItem>
|
||||
<SidebarGroupLabel> {props.groupName}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<RenderSidebarMenu links={props.sidebarLinks} />
|
||||
</SidebarGroupContent>
|
||||
</SidebarMenuItem>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSidebarSubmenu(props: {
|
||||
links: ShadcnSidebarBaseLink[];
|
||||
subMenu: Omit<ShadcnSidebarBaseLink, "href" | "exactMatch">;
|
||||
}) {
|
||||
const sidebar = useSidebar();
|
||||
const isSubnavActive = useIsSubnavActive(props.links);
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<DynamicHeight transition="height 200ms ease">
|
||||
<Collapsible className="group/collapsible" defaultOpen={isSubnavActive}>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
{props.subMenu.icon && (
|
||||
<props.subMenu.icon className="size-4" />
|
||||
)}
|
||||
<span>{props.subMenu.label}</span>
|
||||
<ChevronDownIcon className="ml-auto group-[[data-state=open]]/collapsible:rotate-180 transition-transform" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent className="mt-0.5">
|
||||
<SidebarMenuSub className="pr-0 pl-2">
|
||||
{props.links.map((link) => {
|
||||
return (
|
||||
<SidebarMenuSubItem key={link.href}>
|
||||
<NavLink
|
||||
activeClassName="text-foreground bg-accent "
|
||||
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent hover:text-foreground px-2 py-1.5 rounded-lg w-full"
|
||||
exactMatch={link.exactMatch}
|
||||
href={link.href}
|
||||
isActive={link.isActive}
|
||||
onClick={() => {
|
||||
sidebar.setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
{link.icon && <link.icon className="size-4" />}
|
||||
<span className="whitespace-nowrap">{link.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
</DynamicHeight>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderSidebarMenu(props: { links: ShadcnSidebarLink[] }) {
|
||||
const sidebar = useSidebar();
|
||||
return (
|
||||
<SidebarMenu className="gap-1.5">
|
||||
{props.links.map((link, idx) => {
|
||||
// link
|
||||
if ("href" in link) {
|
||||
return (
|
||||
<SidebarMenuSubItem key={link.href}>
|
||||
<SidebarMenuButton asChild>
|
||||
<NavLink
|
||||
activeClassName="text-foreground bg-accent"
|
||||
className="flex items-center gap-2 text-muted-foreground text-sm hover:bg-accent"
|
||||
exactMatch={link.exactMatch}
|
||||
href={link.href}
|
||||
isActive={link.isActive}
|
||||
onClick={() => {
|
||||
sidebar.setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
{link.icon && <link.icon className="size-4" />}
|
||||
<span>{link.label}</span>
|
||||
</NavLink>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
}
|
||||
|
||||
// separator
|
||||
if ("separator" in link) {
|
||||
return (
|
||||
<SidebarSeparator
|
||||
className="my-1"
|
||||
key={`separator-${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine here
|
||||
idx
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// subnav
|
||||
if ("subMenu" in link) {
|
||||
return (
|
||||
<RenderSidebarSubmenu
|
||||
key={`submenu_$${
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: index is fine here
|
||||
idx
|
||||
}`}
|
||||
links={link.links}
|
||||
subMenu={link.subMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// group
|
||||
return (
|
||||
<RenderSidebarGroup
|
||||
groupName={link.group}
|
||||
key={link.group}
|
||||
sidebarLinks={link.links}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export type MultiStepState<T extends string> = {
|
||||
}
|
||||
| {
|
||||
type: "error";
|
||||
message: React.ReactNode;
|
||||
message: string;
|
||||
};
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -27,6 +27,10 @@ export type MultiStepState<T extends string> = {
|
||||
export function MultiStepStatus<T extends string>(props: {
|
||||
steps: MultiStepState<T>[];
|
||||
onRetry: (step: MultiStepState<T>) => void;
|
||||
renderError?: (
|
||||
step: MultiStepState<T>,
|
||||
errorMessage: string,
|
||||
) => React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DynamicHeight>
|
||||
@@ -66,22 +70,24 @@ export function MultiStepStatus<T extends string>(props: {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{step.status.type === "error" && (
|
||||
<div className="mt-1 space-y-2">
|
||||
<p className="mb-1 text-red-500 text-sm">
|
||||
{step.status.message}
|
||||
</p>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => props.onRetry(step)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{step.status.type === "error"
|
||||
? props.renderError?.(step, step.status.message) || (
|
||||
<div className="mt-1 space-y-2">
|
||||
<p className="mb-1 text-red-500 text-sm">
|
||||
{step.status.message}
|
||||
</p>
|
||||
<Button
|
||||
className="gap-2"
|
||||
onClick={() => props.onRetry(step)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const PaginationButtons = (props: {
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
disabled={activePage === 1}
|
||||
onClick={() => {
|
||||
setPage(activePage - 1);
|
||||
@@ -57,7 +57,7 @@ export const PaginationButtons = (props: {
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
disabled={activePage === totalPages}
|
||||
onClick={() => {
|
||||
setPage(activePage + 1);
|
||||
@@ -78,7 +78,7 @@ export const PaginationButtons = (props: {
|
||||
{pages.map((page) => (
|
||||
<PaginationItem key={page}>
|
||||
<PaginationLink
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
isActive={activePage === page}
|
||||
onClick={() => {
|
||||
setPage(page);
|
||||
@@ -98,7 +98,7 @@ export const PaginationButtons = (props: {
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
disabled={activePage === 1}
|
||||
onClick={() => {
|
||||
setPage(activePage - 1);
|
||||
@@ -111,7 +111,7 @@ export const PaginationButtons = (props: {
|
||||
<>
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
}}
|
||||
@@ -129,7 +129,7 @@ export const PaginationButtons = (props: {
|
||||
{activePage - 1 > 0 && (
|
||||
<PaginationItem className="max-sm:hidden">
|
||||
<PaginationLink
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
onClick={() => {
|
||||
setPage(activePage - 1);
|
||||
}}
|
||||
@@ -140,7 +140,7 @@ export const PaginationButtons = (props: {
|
||||
)}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationLink className="rounded-full" isActive>
|
||||
<PaginationLink className="rounded-full lg:min-w-[38px]" isActive>
|
||||
{activePage}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
@@ -148,7 +148,7 @@ export const PaginationButtons = (props: {
|
||||
{activePage + 1 <= totalPages && (
|
||||
<PaginationItem className="max-sm:hidden">
|
||||
<PaginationLink
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
onClick={() => {
|
||||
setPage(activePage + 1);
|
||||
}}
|
||||
@@ -167,7 +167,7 @@ export const PaginationButtons = (props: {
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationLink
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
onClick={() => {
|
||||
setPage(totalPages);
|
||||
}}
|
||||
@@ -180,7 +180,7 @@ export const PaginationButtons = (props: {
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
className="rounded-full"
|
||||
className="rounded-full lg:min-w-[38px]"
|
||||
disabled={activePage === totalPages}
|
||||
onClick={() => {
|
||||
setPage(activePage + 1);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
@@ -190,7 +191,6 @@ export const SelectWithSearch = React.forwardRef<
|
||||
ref={
|
||||
i === optionsToShow.length - 1 ? lastItemRef : undefined
|
||||
}
|
||||
// biome-ignore lint/a11y/useSemanticElements: TDOO
|
||||
role="option"
|
||||
variant="ghost"
|
||||
>
|
||||
|
||||
@@ -138,7 +138,7 @@ export function UpsellContent(props: {
|
||||
<div className="flex flex-col gap-3 pt-4 sm:flex-row">
|
||||
<Button asChild className="flex-1 py-3 font-semibold" size="lg">
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true&highlight=${props.requiredPlan}`}
|
||||
href={`/team/${props.teamSlug}/~/billing?showPlans=true&highlight=${props.requiredPlan}`}
|
||||
>
|
||||
<CrownIcon className="mr-2 h-4 w-4" />
|
||||
Upgrade to{" "}
|
||||
@@ -146,9 +146,7 @@ export function UpsellContent(props: {
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild className="md:flex-1" size="lg" variant="outline">
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true`}
|
||||
>
|
||||
<Link href={`/team/${props.teamSlug}/~/billing?showPlans=true`}>
|
||||
View All Plans
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -59,7 +59,11 @@ export function WalletAddress(props: {
|
||||
|
||||
// special case for zero address
|
||||
if (address === ZERO_ADDRESS) {
|
||||
return <span className="cursor-pointer font-mono">{shortenedAddress}</span>;
|
||||
return (
|
||||
<span className={cn("cursor-pointer font-mono", props.className)}>
|
||||
{shortenedAddress}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -120,20 +120,18 @@ export const CustomConnectWallet = (props: {
|
||||
|
||||
if ((!isLoggedIn || !account) && loginRequired) {
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
asChild
|
||||
className={props.signInLinkButtonClassName}
|
||||
size="lg"
|
||||
variant="default"
|
||||
<Button
|
||||
asChild
|
||||
className={props.signInLinkButtonClassName}
|
||||
size="lg"
|
||||
variant="default"
|
||||
>
|
||||
<Link
|
||||
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
|
||||
>
|
||||
<Link
|
||||
href={`/login${pathname ? `?next=${encodeURIComponent(pathname)}` : ""}`}
|
||||
>
|
||||
Connect Wallet
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
Connect Wallet
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import type { StaticImageData } from "next/image";
|
||||
import Image from "next/image";
|
||||
import type { FetchDeployMetadataResult } from "thirdweb/contract";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { replaceIpfsUrl } from "@/lib/sdk";
|
||||
import generalContractIcon from "../../../../../public/assets/tw-icons/general.png";
|
||||
|
||||
@@ -26,7 +27,17 @@ export const ContractIdImage: React.FC<ContractIdImageProps> = ({
|
||||
<img
|
||||
alt=""
|
||||
className="size-8 rounded-full"
|
||||
src={replaceIpfsUrl(logo, serverThirdwebClient)}
|
||||
src={
|
||||
DASHBOARD_THIRDWEB_SECRET_KEY
|
||||
? replaceIpfsUrl(
|
||||
logo,
|
||||
getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
)
|
||||
: logo
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ const NetworkFilterCell = React.memo(function NetworkFilterCell({
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
if (chainIds.length < 2) {
|
||||
return <> NETWORK </>;
|
||||
return "NETWORK";
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -37,6 +37,8 @@ type ImportModalProps = {
|
||||
onClose: () => void;
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
projectSlug: string;
|
||||
teamSlug: string;
|
||||
client: ThirdwebClient;
|
||||
type: "contract" | "asset";
|
||||
onSuccess?: () => void;
|
||||
@@ -69,7 +71,9 @@ export const ImportModal: React.FC<ImportModalProps> = (props) => {
|
||||
client={props.client}
|
||||
onSuccess={props.onSuccess}
|
||||
projectId={props.projectId}
|
||||
projectSlug={props.projectSlug}
|
||||
teamId={props.teamId}
|
||||
teamSlug={props.teamSlug}
|
||||
type={props.type}
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -96,6 +100,8 @@ const importFormSchema = z.object({
|
||||
function ImportForm(props: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
teamSlug: string;
|
||||
projectSlug: string;
|
||||
client: ThirdwebClient;
|
||||
type: "contract" | "asset";
|
||||
onSuccess?: () => void;
|
||||
@@ -216,7 +222,7 @@ function ImportForm(props: {
|
||||
addContractToProject.data?.result ? (
|
||||
<Button asChild className="gap-2">
|
||||
<Link
|
||||
href={`/${chainSlug}/${addContractToProject.data.result.contractAddress}`}
|
||||
href={`/team/${props.teamSlug}/${props.projectSlug}/contract/${chainSlug}/${addContractToProject.data.result.contractAddress}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
|
||||
@@ -46,9 +46,9 @@ function AnnouncementBannerUI(props: {
|
||||
export function AnnouncementBanner() {
|
||||
return (
|
||||
<AnnouncementBannerUI
|
||||
href="https://nebula.thirdweb.com/"
|
||||
label="Announcing Nebula App — the most powerful AI for blockchains! Try it now!"
|
||||
trackingLabel="nebula-beta-launch"
|
||||
href="https://blog.thirdweb.com/the-fastest-way-to-build-web3-applications/"
|
||||
label="We have re-branded our Engine, Universal Bridge, and Connect products. Please read the full blog post for details on changes"
|
||||
trackingLabel="product-rebrand"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,12 +38,10 @@ import { createProjectClient } from "@/hooks/useApi";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { projectDomainsSchema, projectNameSchema } from "@/schema/validations";
|
||||
import { toArrFromList } from "@/utils/string";
|
||||
import { createVaultAccountAndAccessToken } from "../../../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
|
||||
const ALL_PROJECT_SERVICES = SERVICES.filter(
|
||||
(srv) =>
|
||||
srv.name !== "relayer" &&
|
||||
srv.name !== "chainsaw" &&
|
||||
srv.name !== "engineCloud", // TODO enable once API server is out
|
||||
(srv) => srv.name !== "relayer" && srv.name !== "chainsaw",
|
||||
);
|
||||
|
||||
export type CreateProjectPrefillOptions = {
|
||||
@@ -66,6 +64,16 @@ const CreateProjectDialog = (props: CreateProjectDialogProps) => {
|
||||
<CreateProjectDialogUI
|
||||
createProject={async (params) => {
|
||||
const res = await createProjectClient(props.teamId, params);
|
||||
await createVaultAccountAndAccessToken({
|
||||
project: res.project,
|
||||
projectSecretKey: res.secret,
|
||||
}).catch((error) => {
|
||||
console.error(
|
||||
"Failed to create vault account and access token",
|
||||
error,
|
||||
);
|
||||
throw error;
|
||||
});
|
||||
return {
|
||||
project: res.project,
|
||||
secret: res.secret,
|
||||
|
||||
@@ -7,6 +7,7 @@ export function CopyAddressButton(props: {
|
||||
address: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
tooltip?: string;
|
||||
variant?:
|
||||
| "primary"
|
||||
| "default"
|
||||
@@ -24,7 +25,7 @@ export function CopyAddressButton(props: {
|
||||
copyIconPosition={props.copyIconPosition}
|
||||
textToCopy={props.address}
|
||||
textToShow={shortenedAddress}
|
||||
tooltip="Copy Address"
|
||||
tooltip={props.tooltip || "Copy Address"}
|
||||
variant={props.variant}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DotsBackgroundPattern(props: { className?: string }) {
|
||||
@@ -16,3 +17,70 @@ export function DotsBackgroundPattern(props: { className?: string }) {
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface GridPatternProps extends React.SVGProps<SVGSVGElement> {
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
squares?: Array<[x: number, y: number]>;
|
||||
strokeDasharray?: string;
|
||||
className?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function GridPattern({
|
||||
width = 40,
|
||||
height = 40,
|
||||
x = -1,
|
||||
y = -1,
|
||||
strokeDasharray = "0",
|
||||
squares,
|
||||
className,
|
||||
...props
|
||||
}: GridPatternProps) {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 h-full w-full fill-current stroke-current",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id={id}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path
|
||||
d={`M.5 ${height}V.5H${width}`}
|
||||
fill="none"
|
||||
strokeDasharray={strokeDasharray}
|
||||
/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${id})`} />
|
||||
{squares && (
|
||||
<svg x={x} y={y} className="overflow-visible" role="presentation">
|
||||
{squares.map(([x, y]) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${x}-${y}`}
|
||||
width={width - 1}
|
||||
height={height - 1}
|
||||
x={x * width + 1}
|
||||
y={y * height + 1}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-lg border border-border bg-card text-card-foreground",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root;
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -50,9 +50,10 @@ const PaginationLink = ({
|
||||
<Button
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"h-auto px-3 py-2",
|
||||
"h-auto px-3 py-2 border border-transparent",
|
||||
className,
|
||||
props.disabled && "cursor-not-allowed",
|
||||
!isActive && "border-border",
|
||||
)}
|
||||
variant={!isActive ? "outline" : "default"}
|
||||
{...props}
|
||||
|
||||
@@ -3,7 +3,9 @@ import "server-only";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "./server-envs";
|
||||
import { getConfiguredThirdwebClient } from "./thirdweb.server";
|
||||
|
||||
// During build time, the secret key might not be available
|
||||
// Create a client that will work for build but may fail at runtime if secret key is needed
|
||||
export const serverThirdwebClient = getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY || "dummy-build-time-secret",
|
||||
teamId: undefined,
|
||||
});
|
||||
|
||||
@@ -76,14 +76,18 @@ export function getConfiguredThirdwebClient(options: {
|
||||
});
|
||||
}
|
||||
|
||||
// During build time, provide fallbacks if credentials are missing
|
||||
const clientId = NEXT_PUBLIC_DASHBOARD_CLIENT_ID || "dummy-build-client";
|
||||
const secretKey = options.secretKey || undefined;
|
||||
|
||||
return createThirdwebClient({
|
||||
clientId: NEXT_PUBLIC_DASHBOARD_CLIENT_ID,
|
||||
clientId: clientId,
|
||||
config: {
|
||||
storage: {
|
||||
gatewayUrl: NEXT_PUBLIC_IPFS_GATEWAY_URL,
|
||||
},
|
||||
},
|
||||
secretKey: options.secretKey,
|
||||
secretKey: secretKey,
|
||||
teamId: options.teamId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,17 +2,11 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import { rotateVaultAccountAndAccessToken } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/transactions/lib/vault.client";
|
||||
import { accountKeys, authorizedWallets } from "../query-keys/cache-keys";
|
||||
|
||||
// FIXME: We keep repeating types, API server should provide them
|
||||
|
||||
export const accountPlan = {
|
||||
enterprise: "enterprise",
|
||||
free: "free",
|
||||
growth: "growth",
|
||||
pro: "pro",
|
||||
} as const;
|
||||
|
||||
export type AuthorizedWallet = {
|
||||
id: string;
|
||||
accountId: string;
|
||||
@@ -318,26 +312,40 @@ export type RotateSecretKeyAPIReturnType = {
|
||||
data: {
|
||||
secret: string;
|
||||
secretMasked: string;
|
||||
secretHash: string;
|
||||
};
|
||||
};
|
||||
|
||||
export async function rotateSecretKeyClient(params: {
|
||||
teamId: string;
|
||||
projectId: string;
|
||||
}) {
|
||||
export async function rotateSecretKeyClient(params: { project: Project }) {
|
||||
const res = await apiServerProxy<RotateSecretKeyAPIReturnType>({
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
pathname: `/v1/teams/${params.teamId}/projects/${params.projectId}/rotate-secret-key`,
|
||||
pathname: `/v1/teams/${params.project.teamId}/projects/${params.project.id}/rotate-secret-key`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
||||
try {
|
||||
// if the project has an encrypted vault admin key, rotate it as well
|
||||
const service = params.project.services.find(
|
||||
(service) => service.name === "engineCloud",
|
||||
);
|
||||
if (service?.encryptedAdminKey) {
|
||||
await rotateVaultAccountAndAccessToken({
|
||||
project: params.project,
|
||||
projectSecretKey: res.data.data.secret,
|
||||
projectSecretHash: res.data.data.secretHash,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to rotate vault admin key", error);
|
||||
}
|
||||
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,21 +7,34 @@ import { embeddedWalletsKeys } from "../query-keys/cache-keys";
|
||||
const fetchAccountList = ({
|
||||
jwt,
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
pageNumber,
|
||||
}: {
|
||||
jwt: string;
|
||||
clientId: string;
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
teamId: string;
|
||||
pageNumber: number;
|
||||
}) => {
|
||||
return async () => {
|
||||
const url = new URL(`${THIRDWEB_EWS_API_HOST}/api/2024-05-05/account/list`);
|
||||
url.searchParams.append("clientId", clientId);
|
||||
|
||||
// Add clientId or ecosystemSlug parameter
|
||||
if (ecosystemSlug) {
|
||||
url.searchParams.append("ecosystemSlug", `ecosystem.${ecosystemSlug}`);
|
||||
} else if (clientId) {
|
||||
url.searchParams.append("clientId", clientId);
|
||||
}
|
||||
|
||||
url.searchParams.append("page", pageNumber.toString());
|
||||
|
||||
const res = await fetch(url.href, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwt}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-thirdweb-team-id": teamId,
|
||||
...(clientId && { "x-client-id": clientId }),
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
@@ -37,23 +50,27 @@ const fetchAccountList = ({
|
||||
};
|
||||
|
||||
export function useEmbeddedWallets(params: {
|
||||
clientId: string;
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
teamId: string;
|
||||
page: number;
|
||||
authToken: string;
|
||||
}) {
|
||||
const { clientId, page, authToken } = params;
|
||||
const { clientId, ecosystemSlug, teamId, page, authToken } = params;
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
return useQuery({
|
||||
enabled: !!address && !!clientId,
|
||||
enabled: !!address && !!(clientId || ecosystemSlug),
|
||||
queryFn: fetchAccountList({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
jwt: authToken,
|
||||
pageNumber: page,
|
||||
}),
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
address || "",
|
||||
clientId,
|
||||
clientId || ecosystemSlug || "",
|
||||
page,
|
||||
),
|
||||
});
|
||||
@@ -66,7 +83,15 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
|
||||
const address = useActiveAccount()?.address;
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ clientId }: { clientId: string }) => {
|
||||
mutationFn: async ({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
}: {
|
||||
clientId?: string;
|
||||
ecosystemSlug?: string;
|
||||
teamId: string;
|
||||
}) => {
|
||||
const responses: WalletUser[] = [];
|
||||
let page = 1;
|
||||
|
||||
@@ -76,12 +101,14 @@ export function useAllEmbeddedWallets(params: { authToken: string }) {
|
||||
}>({
|
||||
queryFn: fetchAccountList({
|
||||
clientId,
|
||||
ecosystemSlug,
|
||||
teamId,
|
||||
jwt: authToken,
|
||||
pageNumber: page,
|
||||
}),
|
||||
queryKey: embeddedWalletsKeys.embeddedWallets(
|
||||
address || "",
|
||||
clientId,
|
||||
clientId || ecosystemSlug || "",
|
||||
page,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ChainIconClient = ({
|
||||
fallback={<img alt="" src={fallbackChainIcon} />}
|
||||
key={resolvedSrc}
|
||||
loading={restProps.loading || "lazy"}
|
||||
skeleton={<div className="animate-pulse rounded-full bg-border" />}
|
||||
skeleton={<span className="animate-pulse rounded-full bg-border" />}
|
||||
src={resolvedSrc}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SVGProps } from "react";
|
||||
export function DiscordIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
enable-background="new 0 0 100 100"
|
||||
enableBackground="new 0 0 100 100"
|
||||
viewBox="0 0 100 100"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
|
||||
@@ -192,6 +192,7 @@ export function teamSubscriptionsStub(
|
||||
currentPeriodEnd: "2024-12-15T20:56:06.000Z",
|
||||
currentPeriodStart: "2024-11-15T20:56:06.000Z",
|
||||
id: "sub-1",
|
||||
skus: [],
|
||||
status: "active",
|
||||
trialEnd: overrides?.trialEnd || null,
|
||||
trialStart: null,
|
||||
@@ -212,6 +213,7 @@ export function teamSubscriptionsStub(
|
||||
currentPeriodEnd: "2024-12-15T20:56:06.000Z",
|
||||
currentPeriodStart: "2024-11-15T20:56:15.000Z",
|
||||
id: "sub-2",
|
||||
skus: [],
|
||||
status: "active",
|
||||
trialEnd: null,
|
||||
trialStart: null,
|
||||
@@ -229,25 +231,19 @@ export function teamSubscriptionsStub(
|
||||
// In-App Wallets
|
||||
{
|
||||
amount: usage.inAppWalletAmount?.amount || 0,
|
||||
description: `${
|
||||
usage.inAppWalletAmount?.quantity || 0
|
||||
} x In-App Wallets (Tier 1 at $0.00 / month)`,
|
||||
description: `${usage.inAppWalletAmount?.quantity || 0} x In-App Wallets (Tier 1 at $0.00 / month)`,
|
||||
thirdwebSku: "usage:in_app_wallet",
|
||||
},
|
||||
// AA Sponsorship
|
||||
{
|
||||
amount: usage.aaSponsorshipAmount?.amount || 0,
|
||||
description: `${
|
||||
usage.aaSponsorshipAmount?.quantity || 0
|
||||
} x AA Gas Sponsorship (at $0.011 / month)`,
|
||||
description: `${usage.aaSponsorshipAmount?.quantity || 0} x AA Gas Sponsorship (at $0.011 / month)`,
|
||||
thirdwebSku: "usage:aa_sponsorship",
|
||||
},
|
||||
// OP Grant
|
||||
{
|
||||
amount: usage.aaSponsorshipOpGrantAmount?.amount || 0,
|
||||
description: `${
|
||||
usage.aaSponsorshipOpGrantAmount?.quantity || 0
|
||||
} x AA Gas Sponsorship (OP) (at $0.011 / month)`,
|
||||
description: `${usage.aaSponsorshipOpGrantAmount?.quantity || 0} x AA Gas Sponsorship (OP) (at $0.011 / month)`,
|
||||
thirdwebSku: "usage:aa_sponsorship_op_grant",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -39,12 +39,6 @@ export interface TransactionStats {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RpcMethodStats {
|
||||
date: string;
|
||||
evmMethod: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface EngineCloudStats {
|
||||
date: string;
|
||||
chainId: string;
|
||||
@@ -72,10 +66,36 @@ export interface UniversalBridgeWalletStats {
|
||||
developerFeeUsdCents: number;
|
||||
}
|
||||
|
||||
export interface WebhookRequestStats {
|
||||
date: string;
|
||||
webhookId: string;
|
||||
httpStatusCode: number;
|
||||
totalRequests: number;
|
||||
}
|
||||
|
||||
export interface WebhookLatencyStats {
|
||||
date: string;
|
||||
webhookId: string;
|
||||
p50LatencyMs: number;
|
||||
p90LatencyMs: number;
|
||||
p99LatencyMs: number;
|
||||
}
|
||||
|
||||
export interface WebhookSummaryStats {
|
||||
webhookId: string;
|
||||
totalRequests: number;
|
||||
successRequests: number;
|
||||
errorRequests: number;
|
||||
successRate: number;
|
||||
avgLatencyMs: number;
|
||||
errorBreakdown: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface AnalyticsQueryParams {
|
||||
teamId: string;
|
||||
projectId?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
period?: "day" | "week" | "month" | "year" | "all";
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,8 @@ export type ProductSKU =
|
||||
| "usage:aa_sponsorship"
|
||||
| "usage:aa_sponsorship_op_grant"
|
||||
| null;
|
||||
|
||||
export type ChainInfraSKU =
|
||||
| "chain:infra:rpc"
|
||||
| "chain:infra:insight"
|
||||
| "chain:infra:account_abstraction";
|
||||
|
||||
@@ -23,7 +23,6 @@ export const TEAM_PLANS: Record<
|
||||
features: [
|
||||
"Email Support",
|
||||
"48hr Guaranteed Response",
|
||||
"Invite Team Members",
|
||||
"Custom In-App Wallet Auth",
|
||||
],
|
||||
price: 99,
|
||||
|
||||
+57
-27
@@ -1,5 +1,7 @@
|
||||
import { defineChain } from "thirdweb";
|
||||
import { getChainMetadata } from "thirdweb/chains";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { defineChain, getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import { type ChainMetadata, getChainMetadata } from "thirdweb/chains";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
@@ -53,7 +55,7 @@ export async function RouteListCard({
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<Card className="h-full w-full transition-colors hover:border-active-border">
|
||||
<Card className="h-full w-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
@@ -80,32 +82,60 @@ export async function RouteListCard({
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="px-4 pt-0 pb-4">
|
||||
<table className="w-full">
|
||||
<tbody className="text-sm [&_td>*]:min-h-[25px]">
|
||||
<tr>
|
||||
<th className="text-left font-normal text-base">
|
||||
{originTokenName === "ETH"
|
||||
? originChain.nativeCurrency.name
|
||||
: originTokenName}
|
||||
</th>
|
||||
<td className="text-right text-base text-muted-foreground">
|
||||
{originChain.name}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="text-left font-normal text-base">
|
||||
{destinationTokenName === "ETH"
|
||||
? destinationChain.nativeCurrency.name
|
||||
: destinationTokenName}
|
||||
</th>
|
||||
<td className="text-right text-base text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex justify-between gap-2">
|
||||
<TokenName
|
||||
chainMetadata={originChain}
|
||||
tokenAddress={originTokenAddress}
|
||||
tokenName={originTokenName}
|
||||
/>
|
||||
<div className="text-right text-base text-muted-foreground">
|
||||
{originChain.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<TokenName
|
||||
chainMetadata={destinationChain}
|
||||
tokenAddress={destinationTokenAddress}
|
||||
tokenName={destinationTokenName}
|
||||
/>
|
||||
<div className="text-right text-base text-muted-foreground">
|
||||
{destinationChain.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
function TokenName(props: {
|
||||
tokenAddress: string;
|
||||
tokenName: string;
|
||||
chainMetadata: ChainMetadata;
|
||||
}) {
|
||||
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
|
||||
|
||||
if (isERC20) {
|
||||
return (
|
||||
<Link
|
||||
className="flex items-center gap-1.5 font-normal text-base group/link hover:underline underline-offset-4 decoration-muted-foreground/50 decoration-dotted"
|
||||
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{props.tokenName}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground group-hover/link:text-foreground" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-left font-normal text-base">
|
||||
{props.chainMetadata.nativeCurrency.name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+68
-60
@@ -1,5 +1,14 @@
|
||||
import { defineChain, getChainMetadata } from "thirdweb/chains";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getAddress, NATIVE_TOKEN_ADDRESS } from "thirdweb";
|
||||
import {
|
||||
type ChainMetadata,
|
||||
defineChain,
|
||||
getChainMetadata,
|
||||
} from "thirdweb/chains";
|
||||
import { shortenAddress } from "thirdweb/utils";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
@@ -52,37 +61,14 @@ export async function RouteListRow({
|
||||
]);
|
||||
|
||||
return (
|
||||
<TableRow className="group transition-colors hover:bg-accent/50" linkBox>
|
||||
<TableRow className="group">
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// For now we're using a normal img tag because the domain for these images is unknown
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={originTokenAddress}
|
||||
className="size-7 rounded-full border border-border/50 shadow-sm transition-transform group-hover:scale-105"
|
||||
src={resolvedOriginTokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{originTokenSymbol && (
|
||||
<CopyTextButton
|
||||
className="relative z-10 font-medium text-base"
|
||||
copyIconPosition="right"
|
||||
textToCopy={originTokenAddress}
|
||||
textToShow={
|
||||
originTokenSymbol === "ETH"
|
||||
? originChain.nativeCurrency.symbol
|
||||
: originTokenSymbol
|
||||
}
|
||||
tooltip="Copy Token Address"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TokenInfo
|
||||
chainMetadata={originChain}
|
||||
tokenAddress={originTokenAddress}
|
||||
tokenIconUri={resolvedOriginTokenIconUri}
|
||||
tokenSymbol={originTokenSymbol}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
@@ -90,34 +76,12 @@ export async function RouteListRow({
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
alt={destinationTokenAddress}
|
||||
className="size-7 rounded-full border border-border/50 shadow-sm transition-transform group-hover:scale-105"
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{destinationTokenSymbol && (
|
||||
<CopyTextButton
|
||||
className="relative z-10 font-medium text-base"
|
||||
copyIconPosition="right"
|
||||
textToCopy={destinationTokenAddress}
|
||||
textToShow={
|
||||
destinationTokenSymbol === "ETH"
|
||||
? destinationChain.nativeCurrency.symbol
|
||||
: destinationTokenSymbol
|
||||
}
|
||||
tooltip="Copy Token Address"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TokenInfo
|
||||
chainMetadata={destinationChain}
|
||||
tokenAddress={destinationTokenAddress}
|
||||
tokenIconUri={resolvedDestinationTokenIconUri}
|
||||
tokenSymbol={destinationTokenSymbol}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-muted-foreground/90">
|
||||
@@ -126,3 +90,47 @@ export async function RouteListRow({
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
const nativeTokenAddress = getAddress(NATIVE_TOKEN_ADDRESS);
|
||||
|
||||
function TokenInfo(props: {
|
||||
tokenAddress: string;
|
||||
tokenSymbol: string | undefined;
|
||||
chainMetadata: ChainMetadata;
|
||||
tokenIconUri: string | undefined;
|
||||
}) {
|
||||
const isERC20 = getAddress(props.tokenAddress) !== nativeTokenAddress;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{props.tokenIconUri ? (
|
||||
<Img
|
||||
alt={props.tokenAddress}
|
||||
className="size-7 rounded-full border border-border/50"
|
||||
src={props.tokenIconUri}
|
||||
/>
|
||||
) : (
|
||||
<div className="size-7 rounded-full bg-muted-foreground/20" />
|
||||
)}
|
||||
{isERC20 ? (
|
||||
<Button
|
||||
asChild
|
||||
className="h-auto w-auto py-1 px-1.5 gap-2"
|
||||
variant="ghost"
|
||||
>
|
||||
<Link
|
||||
href={`https://thirdweb.com/${props.chainMetadata.slug}/${props.tokenAddress}`}
|
||||
target="_blank"
|
||||
>
|
||||
{props.tokenSymbol || shortenAddress(props.tokenAddress)}
|
||||
<ExternalLinkIcon className="size-3.5 text-muted-foreground" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="font-medium text-sm px-1.5 py-1">
|
||||
{props.chainMetadata.nativeCurrency.symbol}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+7
-15
@@ -86,22 +86,14 @@ export async function RoutesData(props: {
|
||||
<p className="text-2xl">No Results found</p>
|
||||
</div>
|
||||
) : props.activeView === "table" ? (
|
||||
<TableContainer className="overflow-hidden rounded-xl border border-border/50 bg-card/50 shadow-sm transition-all">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader className="z-0">
|
||||
<TableRow className="border-border/50 border-b bg-muted/50">
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Origin Token
|
||||
</TableHead>
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Origin Chain
|
||||
</TableHead>
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Destination Token
|
||||
</TableHead>
|
||||
<TableHead className="py-4 font-medium text-muted-foreground/80 text-xs uppercase tracking-wider">
|
||||
Destination Chain
|
||||
</TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Origin Token</TableHead>
|
||||
<TableHead>Origin Chain</TableHead>
|
||||
<TableHead>Destination Token</TableHead>
|
||||
<TableHead>Destination Chain</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
import { useTheme } from "next-themes";
|
||||
import { defineChain, type ThirdwebClient } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { BuyWidget } from "thirdweb/react";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
|
||||
export function BuyFundsSection(props: {
|
||||
chain: ChainMetadata;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<section className="flex flex-col gap-4 items-center justify-center">
|
||||
<BuyWidget
|
||||
amount="0"
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain={defineChain(props.chain.chainId)}
|
||||
client={props.client}
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
"use client";
|
||||
import { FileTextIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { SectionTitle } from "../server/SectionTitle";
|
||||
|
||||
export default function NextSteps(props: { chain: ChainMetadata }) {
|
||||
const { chain } = props;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Next Steps" />
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
|
||||
<div className="relative flex gap-3 rounded-lg border bg-card p-4 pr-8 transition-colors hover:border-active-border">
|
||||
<FileTextIcon className="mt-0.5 size-5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="mb-1.5 font-medium">
|
||||
<Link
|
||||
className="before:absolute before:inset-0"
|
||||
href={
|
||||
"https://blog.thirdweb.com/supercharge-user-adoption-integrate-embedded-wallets-in-minutes/"
|
||||
}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Create a login for {chain.name}
|
||||
</Link>
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Supercharge User Adoption—Integrate In-App Wallets in Minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
"use client";
|
||||
import { useTheme } from "next-themes";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { PayEmbed } from "thirdweb/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { defineDashboardChain } from "@/lib/defineDashboardChain";
|
||||
import { getSDKTheme } from "../../../../../../../../@/utils/sdk-component-theme";
|
||||
|
||||
export function PayModalButton(props: {
|
||||
chainId: number;
|
||||
label: string;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full" variant="primary">
|
||||
{props.label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="border-none bg-transparent p-0 md:max-w-[360px]"
|
||||
dialogCloseClassName="focus:ring-0"
|
||||
dialogOverlayClassName="backdrop-blur-lg"
|
||||
>
|
||||
<PayEmbed
|
||||
className="!w-auto"
|
||||
client={props.client}
|
||||
payOptions={{
|
||||
prefillBuy: {
|
||||
allowEdits: {
|
||||
amount: true,
|
||||
chain: false,
|
||||
token: true,
|
||||
},
|
||||
// Do not include local chain overrides for chain pages
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: defineDashboardChain(props.chainId, undefined),
|
||||
},
|
||||
}}
|
||||
theme={getSDKTheme(theme === "dark" ? "dark" : "light")}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
-92
@@ -1,92 +0,0 @@
|
||||
import { useId } from "react";
|
||||
|
||||
export function CreditCardIcon(props: { bg: string; className?: string }) {
|
||||
const linearGradientId = useId();
|
||||
const linearGradientId2 = useId();
|
||||
const linearGradientId3 = useId();
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={props.className}
|
||||
fill="none"
|
||||
height={48}
|
||||
viewBox="0 0 48 48"
|
||||
width={48}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect fill={props.bg} height={48} rx={16} width={48} />
|
||||
<rect
|
||||
height={46}
|
||||
rx={15}
|
||||
stroke={`url(#${linearGradientId})`}
|
||||
strokeOpacity="0.2"
|
||||
strokeWidth={2}
|
||||
width={46}
|
||||
x={1}
|
||||
y={1}
|
||||
/>
|
||||
<rect fill={props.bg} height={38} rx={11} width={38} x={5} y={5} />
|
||||
<rect
|
||||
height={38}
|
||||
rx={11}
|
||||
stroke={`url(#${linearGradientId2})`}
|
||||
strokeWidth={2}
|
||||
width={38}
|
||||
x={5}
|
||||
y={5}
|
||||
/>
|
||||
<path
|
||||
d="M32.3337 22.3333H15.667M23.167 25.6666H19.0003M15.667 20.8333L15.667 27.1666C15.667 28.1 15.667 28.5668 15.8486 28.9233C16.0084 29.2369 16.2634 29.4918 16.577 29.6516C16.9335 29.8333 17.4002 29.8333 18.3337 29.8333L29.667 29.8333C30.6004 29.8333 31.0671 29.8333 31.4236 29.6516C31.7372 29.4918 31.9922 29.2369 32.152 28.9233C32.3337 28.5668 32.3337 28.1 32.3337 27.1666V20.8333C32.3337 19.8999 32.3337 19.4332 32.152 19.0766C31.9922 18.763 31.7372 18.5081 31.4236 18.3483C31.0671 18.1666 30.6004 18.1666 29.667 18.1666L18.3337 18.1666C17.4002 18.1666 16.9335 18.1666 16.577 18.3483C16.2634 18.5081 16.0084 18.763 15.8486 19.0766C15.667 19.4332 15.667 19.8999 15.667 20.8333Z"
|
||||
stroke={`url(#${linearGradientId3})`}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id={linearGradientId}
|
||||
x1="-3.26045"
|
||||
x2="45.7807"
|
||||
y1="15.4021"
|
||||
y2="50.1547"
|
||||
>
|
||||
<stop stopColor="#F213A4" />
|
||||
<stop offset="0.1517" stopColor="#E011A7" />
|
||||
<stop offset="0.4554" stopColor="#B20DAF" />
|
||||
<stop offset="0.8789" stopColor="#6806BB" />
|
||||
<stop offset={1} stopColor="#5204BF" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id={linearGradientId2}
|
||||
x1="1.28296"
|
||||
x2="42.1506"
|
||||
y1="16.835"
|
||||
y2="45.7956"
|
||||
>
|
||||
<stop stopColor="#F213A4" />
|
||||
<stop offset="0.1517" stopColor="#E011A7" />
|
||||
<stop offset="0.4554" stopColor="#B20DAF" />
|
||||
<stop offset="0.8789" stopColor="#6806BB" />
|
||||
<stop offset={1} stopColor="#5204BF" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id={linearGradientId3}
|
||||
x1="14.5349"
|
||||
x2="27.1676"
|
||||
y1="21.9102"
|
||||
y2="34.6989"
|
||||
>
|
||||
<stop stopColor="#F213A4" />
|
||||
<stop offset="0.1517" stopColor="#E011A7" />
|
||||
<stop offset="0.4554" stopColor="#B20DAF" />
|
||||
<stop offset="0.8789" stopColor="#6806BB" />
|
||||
<stop offset={1} stopColor="#5204BF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { ChainIcon } from "../../../../components/server/chain-icon";
|
||||
import { PayModalButton } from "../client/PayModal";
|
||||
import { CreditCardIcon } from "../icons/CreditCardIcon";
|
||||
import { SectionTitle } from "./SectionTitle";
|
||||
|
||||
export function BuyFundsSection(props: {
|
||||
chain: ChainMetadata;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const sanitizedChainName = props.chain.name.replace("Mainnet", "").trim();
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Bridge" />
|
||||
<div className="flex justify-center rounded-lg border border-border bg-card px-4 py-10">
|
||||
<div className="flex max-w-[520px] flex-col items-center ">
|
||||
<div className="flex items-center">
|
||||
<ChainIcon
|
||||
className="-mr-2 size-12 rounded-full border p-1"
|
||||
iconUrl={props.chain.icon?.url}
|
||||
/>
|
||||
<CreditCardIcon
|
||||
bg="hsl(var(--background))"
|
||||
className="-ml-2 size-12"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
<h2 className="px-4 text-center font-semibold text-lg tracking-tight">
|
||||
Bridge Funds to {sanitizedChainName}
|
||||
</h2>
|
||||
|
||||
<div className="h-2" />
|
||||
|
||||
<p className="max-w-[520px] px-4 text-center text-muted-foreground text-sm">
|
||||
Get {props.chain.nativeCurrency.symbol} on {sanitizedChainName} with
|
||||
fiat or any token on another chain.
|
||||
</p>
|
||||
|
||||
<div className="h-8" />
|
||||
|
||||
<PayModalButton
|
||||
chainId={props.chain.chainId}
|
||||
client={props.client}
|
||||
label={`Bridge to ${sanitizedChainName}`}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
<Link
|
||||
className="inline-flex items-center gap-1.5 text-muted-foreground text-sm hover:text-foreground"
|
||||
href="https://portal.thirdweb.com/connect/pay/overview"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more about Universal Bridge
|
||||
<ExternalLinkIcon className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SectionTitle } from "./SectionTitle";
|
||||
|
||||
export function ClaimChainSection() {
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle title="Claim Chain" />
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<p className="mb-1"> This chain page is unclaimed </p>
|
||||
<p className="mb-5 text-muted-foreground text-sm">
|
||||
Are you the owner of this ecosystem? Claim this page to add additional
|
||||
information on this page to help your developers get started!
|
||||
</p>
|
||||
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
className="gap-2"
|
||||
href="https://share.hsforms.com/1qAViW3U5SoKbabNhRZy4Kgea58c"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span>Claim Chain</span>
|
||||
<ExternalLinkIcon className="-mt-[3px] size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+23
-9
@@ -1,7 +1,8 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { useId } from "react";
|
||||
import { download } from "thirdweb/storage";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { fetchChain } from "@/utils/fetchChain";
|
||||
|
||||
// Route segment config
|
||||
@@ -81,16 +82,29 @@ export default async function Image({
|
||||
fetch(new URL("og-lib/fonts/inter/700.ttf", import.meta.url)).then((res) =>
|
||||
res.arrayBuffer(),
|
||||
),
|
||||
// download the chain icon if there is one
|
||||
chain.icon?.url && hasWorkingChainIcon
|
||||
? download({
|
||||
client: serverThirdwebClient,
|
||||
uri: chain.icon.url,
|
||||
}).then((res) => res.arrayBuffer())
|
||||
// download the chain icon if there is one and secret key is available
|
||||
chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY
|
||||
? (async () => {
|
||||
try {
|
||||
const client = getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
});
|
||||
const response = await download({
|
||||
client,
|
||||
uri: chain.icon?.url || "",
|
||||
});
|
||||
return response.arrayBuffer();
|
||||
} catch (error) {
|
||||
// If download fails, return undefined to fallback to no icon
|
||||
console.warn("Failed to download chain icon:", error);
|
||||
return undefined;
|
||||
}
|
||||
})()
|
||||
: undefined,
|
||||
// download the background image (based on chain)
|
||||
fetch(
|
||||
chain.icon?.url && hasWorkingChainIcon
|
||||
chain.icon?.url && hasWorkingChainIcon && DASHBOARD_THIRDWEB_SECRET_KEY
|
||||
? new URL(
|
||||
"og-lib/assets/chain/bg-with-icon.png",
|
||||
|
||||
@@ -118,7 +132,7 @@ export default async function Image({
|
||||
/>
|
||||
{/* the actual component starts here */}
|
||||
|
||||
{hasWorkingChainIcon && (
|
||||
{hasWorkingChainIcon && chainIcon && (
|
||||
<img
|
||||
alt=""
|
||||
// @ts-expect-error - TS doesn't know about the ImageResponse component
|
||||
|
||||
@@ -2,10 +2,8 @@ import { CircleAlertIcon } from "lucide-react";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { getRawAccount } from "../../../../account/settings/getAccount";
|
||||
import { getChain, getChainMetadata } from "../../utils";
|
||||
import NextSteps from "./components/client/NextSteps";
|
||||
import { BuyFundsSection } from "./components/server/BuyFundsSection";
|
||||
import { BuyFundsSection } from "./components/client/BuyFundsSection";
|
||||
import { ChainOverviewSection } from "./components/server/ChainOverviewSection";
|
||||
import { ClaimChainSection } from "./components/server/ClaimChainSection";
|
||||
import { ChainCTA } from "./components/server/cta-card";
|
||||
import { ExplorersSection } from "./components/server/explorer-section";
|
||||
import { FaucetSection } from "./components/server/FaucetSection";
|
||||
@@ -60,11 +58,6 @@ export default async function Page(props: {
|
||||
{chain.services.filter((s) => s.enabled).length > 0 && (
|
||||
<SupportedProductsSection services={chain.services} />
|
||||
)}
|
||||
{/*Next Steps */}
|
||||
<NextSteps chain={chain} />
|
||||
|
||||
{/* Claim Chain */}
|
||||
{!chainMetadata && <ClaimChainSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,5 @@
|
||||
// biome-ignore-all lint/nursery/noNestedComponentDefinitions: TODO
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
|
||||
|
||||
import {
|
||||
IconButton,
|
||||
@@ -256,7 +257,6 @@ export const MarketplaceTable: React.FC<MarketplaceTableProps> = ({
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={rowIndex}
|
||||
onClick={() => setTokenRow(row.original)}
|
||||
// biome-ignore lint/a11y/useSemanticElements: FIXME
|
||||
role="group"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
|
||||
+182
-187
@@ -130,6 +130,7 @@ const getClaimConditionTypeFromPhase = (
|
||||
|
||||
if (phase.snapshot) {
|
||||
if (
|
||||
phase.maxClaimablePerWallet?.toString() === "0" &&
|
||||
phase.price === "0" &&
|
||||
typeof phase.snapshot !== "string" &&
|
||||
phase.snapshot.length === 1 &&
|
||||
@@ -464,204 +465,198 @@ export const ClaimConditionsForm: React.FC<ClaimConditionsFormProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex as="form" direction="column" gap={10} onSubmit={handleFormSubmit}>
|
||||
<Flex direction="column" gap={6}>
|
||||
{/* Show the reason why the form is disabled */}
|
||||
{!isAdmin && (
|
||||
<Text>Connect with admin wallet to edit claim conditions.</Text>
|
||||
)}
|
||||
{controlledFields.map((field, index) => {
|
||||
const dropType: DropType = field.snapshot
|
||||
? field.maxClaimablePerWallet?.toString() === "0"
|
||||
? "specific"
|
||||
: "overrides"
|
||||
: "any";
|
||||
<Flex as="form" direction="column" gap={10} onSubmit={handleFormSubmit}>
|
||||
<Flex direction="column" gap={6}>
|
||||
{/* Show the reason why the form is disabled */}
|
||||
{!isAdmin && (
|
||||
<Text>Connect with admin wallet to edit claim conditions.</Text>
|
||||
)}
|
||||
{controlledFields.map((field, index) => {
|
||||
const dropType: DropType = field.snapshot
|
||||
? field.maxClaimablePerWallet?.toString() === "0"
|
||||
? "specific"
|
||||
: "overrides"
|
||||
: "any";
|
||||
|
||||
const claimConditionType = getClaimConditionTypeFromPhase(field);
|
||||
const claimConditionType = getClaimConditionTypeFromPhase(field);
|
||||
|
||||
const isActive = activePhaseId === field.id;
|
||||
const isActive = activePhaseId === field.id;
|
||||
|
||||
const snapshotValue = field.snapshot?.map((v) =>
|
||||
typeof v === "string"
|
||||
? {
|
||||
address: v,
|
||||
currencyAddress: ZERO_ADDRESS,
|
||||
maxClaimable: "unlimited",
|
||||
price: "unlimited",
|
||||
}
|
||||
: {
|
||||
...v,
|
||||
currencyAddress: v?.currencyAddress || ZERO_ADDRESS,
|
||||
maxClaimable: v?.maxClaimable?.toString() || "unlimited",
|
||||
price: v?.price?.toString() || "unlimited",
|
||||
},
|
||||
);
|
||||
const snapshotValue = field.snapshot?.map((v) =>
|
||||
typeof v === "string"
|
||||
? {
|
||||
address: v,
|
||||
currencyAddress: ZERO_ADDRESS,
|
||||
maxClaimable: "unlimited",
|
||||
price: "unlimited",
|
||||
}
|
||||
: {
|
||||
...v,
|
||||
currencyAddress: v?.currencyAddress || ZERO_ADDRESS,
|
||||
maxClaimable: v?.maxClaimable?.toString() || "unlimited",
|
||||
price: v?.price?.toString() || "unlimited",
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={`snapshot_${field.id}_${index}`}>
|
||||
<SnapshotViewerSheet
|
||||
client={contract.client}
|
||||
dropType={dropType}
|
||||
isDisabled={!canEditForm}
|
||||
isOpen={openSnapshotIndex === index}
|
||||
onClose={() => {
|
||||
setOpenSnapshotIndex(-1);
|
||||
}}
|
||||
setSnapshot={(snapshot) =>
|
||||
form.setValue(`phases.${index}.snapshot`, snapshot)
|
||||
}
|
||||
value={snapshotValue}
|
||||
/>
|
||||
return (
|
||||
<Fragment key={`snapshot_${field.id}_${index}`}>
|
||||
<SnapshotViewerSheet
|
||||
client={contract.client}
|
||||
dropType={dropType}
|
||||
isDisabled={!canEditForm}
|
||||
isOpen={openSnapshotIndex === index}
|
||||
onClose={() => {
|
||||
setOpenSnapshotIndex(-1);
|
||||
}}
|
||||
setSnapshot={(snapshot) =>
|
||||
form.setValue(`phases.${index}.snapshot`, snapshot)
|
||||
}
|
||||
value={snapshotValue}
|
||||
/>
|
||||
|
||||
<ClaimsConditionFormContext.Provider
|
||||
value={{
|
||||
claimConditionType,
|
||||
dropType,
|
||||
field,
|
||||
form,
|
||||
formDisabled: !canEditForm,
|
||||
isActive,
|
||||
isAdmin,
|
||||
isColumn,
|
||||
isErc20,
|
||||
isMultiPhase,
|
||||
phaseIndex: index,
|
||||
setOpenSnapshotIndex,
|
||||
tokenDecimals: tokenDecimals.data,
|
||||
}}
|
||||
>
|
||||
<ClaimConditionsPhase
|
||||
contract={contract}
|
||||
isPending={sendTx.isPending}
|
||||
onRemove={() => {
|
||||
removePhase(index);
|
||||
}}
|
||||
/>
|
||||
</ClaimsConditionFormContext.Provider>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{phases?.length === 0 && (
|
||||
<Alert borderRadius="md" status="warning">
|
||||
<AlertIcon />
|
||||
<div className="flex flex-col">
|
||||
<AlertTitle as={Heading} size="label.lg">
|
||||
{isMultiPhase
|
||||
? "Missing Claim Phases"
|
||||
: "Missing Claim Conditions"}
|
||||
</AlertTitle>
|
||||
<AlertDescription as={Text}>
|
||||
{isMultiPhase
|
||||
? "You need to set at least one claim phase for people to claim this drop."
|
||||
: "You need to set claim conditions for people to claim this drop."}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={2}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
<AdminOnly contract={contract}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
isDisabled={
|
||||
sendTx.isPending || (!isMultiPhase && phases?.length > 0)
|
||||
}
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
size="sm"
|
||||
variant={phases?.length > 0 ? "outline" : "solid"}
|
||||
>
|
||||
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
|
||||
</MenuButton>
|
||||
<MenuList
|
||||
borderRadius="lg"
|
||||
overflow="hidden"
|
||||
zIndex="overlay"
|
||||
>
|
||||
{Object.keys(ClaimConditionTypeData).map((key) => {
|
||||
const type = key as ClaimConditionType;
|
||||
|
||||
if (type === "custom") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={type}
|
||||
onClick={() => {
|
||||
addPhase(type);
|
||||
// TODO: Automatically start editing the new phase after adding it
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{ClaimConditionTypeData[type].name}
|
||||
<TooltipBox
|
||||
content={
|
||||
<Text size="body.md">
|
||||
{ClaimConditionTypeData[type].description}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</AdminOnly>
|
||||
|
||||
{controlledFields.some((field) => field.fromSdk) && (
|
||||
<ResetClaimEligibility
|
||||
<ClaimsConditionFormContext.Provider
|
||||
value={{
|
||||
claimConditionType,
|
||||
dropType,
|
||||
field,
|
||||
form,
|
||||
formDisabled: !canEditForm,
|
||||
isActive,
|
||||
isAdmin,
|
||||
isColumn,
|
||||
isErc20,
|
||||
isMultiPhase,
|
||||
phaseIndex: index,
|
||||
setOpenSnapshotIndex,
|
||||
tokenDecimals: tokenDecimals.data,
|
||||
}}
|
||||
>
|
||||
<ClaimConditionsPhase
|
||||
contract={contract}
|
||||
isErc20={isErc20}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isMultiphase={isMultiPhase}
|
||||
tokenId={tokenId}
|
||||
isPending={sendTx.isPending}
|
||||
onRemove={() => {
|
||||
removePhase(index);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ClaimsConditionFormContext.Provider>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-row">
|
||||
<AdminOnly contract={contract} fallback={<Box pb={5} />}>
|
||||
<Flex alignItems="center" gap={3} justifyContent="center">
|
||||
{(hasRemovedPhases || hasAddedPhases) && (
|
||||
<Text color="red.500" fontWeight="bold">
|
||||
You have unsaved changes
|
||||
</Text>
|
||||
)}
|
||||
{controlledFields.length > 0 ||
|
||||
hasRemovedPhases ||
|
||||
!isMultiPhase ? (
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
disabled={claimConditionsQuery.isPending}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
{claimConditionsQuery.isPending
|
||||
? "Saving Phases"
|
||||
: "Save Phases"}
|
||||
</TransactionButton>
|
||||
) : null}
|
||||
</Flex>
|
||||
</AdminOnly>
|
||||
{phases?.length === 0 && (
|
||||
<Alert borderRadius="md" status="warning">
|
||||
<AlertIcon />
|
||||
<div className="flex flex-col">
|
||||
<AlertTitle as={Heading} size="label.lg">
|
||||
{isMultiPhase
|
||||
? "Missing Claim Phases"
|
||||
: "Missing Claim Conditions"}
|
||||
</AlertTitle>
|
||||
<AlertDescription as={Text}>
|
||||
{isMultiPhase
|
||||
? "You need to set at least one claim phase for people to claim this drop."
|
||||
: "You need to set claim conditions for people to claim this drop."}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Flex>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={2}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
<AdminOnly contract={contract}>
|
||||
<Menu>
|
||||
<MenuButton
|
||||
as={Button}
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
isDisabled={
|
||||
sendTx.isPending || (!isMultiPhase && phases?.length > 0)
|
||||
}
|
||||
leftIcon={<PlusIcon className="size-5" />}
|
||||
size="sm"
|
||||
variant={phases?.length > 0 ? "outline" : "solid"}
|
||||
>
|
||||
Add {isMultiPhase ? "Phase" : "Claim Conditions"}
|
||||
</MenuButton>
|
||||
<MenuList borderRadius="lg" overflow="hidden" zIndex="overlay">
|
||||
{Object.keys(ClaimConditionTypeData).map((key) => {
|
||||
const type = key as ClaimConditionType;
|
||||
|
||||
if (type === "custom") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={type}
|
||||
onClick={() => {
|
||||
addPhase(type);
|
||||
// TODO: Automatically start editing the new phase after adding it
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{ClaimConditionTypeData[type].name}
|
||||
<TooltipBox
|
||||
content={
|
||||
<Text size="body.md">
|
||||
{ClaimConditionTypeData[type].description}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</AdminOnly>
|
||||
|
||||
{controlledFields.some((field) => field.fromSdk) && (
|
||||
<ResetClaimEligibility
|
||||
contract={contract}
|
||||
isErc20={isErc20}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isMultiphase={isMultiPhase}
|
||||
tokenId={tokenId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row">
|
||||
<AdminOnly contract={contract} fallback={<Box pb={5} />}>
|
||||
<Flex alignItems="center" gap={3} justifyContent="center">
|
||||
{(hasRemovedPhases || hasAddedPhases) && (
|
||||
<Text color="red.500" fontWeight="bold">
|
||||
You have unsaved changes
|
||||
</Text>
|
||||
)}
|
||||
{controlledFields.length > 0 ||
|
||||
hasRemovedPhases ||
|
||||
!isMultiPhase ? (
|
||||
<TransactionButton
|
||||
client={contract.client}
|
||||
disabled={claimConditionsQuery.isPending}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendTx.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
{claimConditionsQuery.isPending
|
||||
? "Saving Phases"
|
||||
: "Save Phases"}
|
||||
</TransactionButton>
|
||||
) : null}
|
||||
</Flex>
|
||||
</AdminOnly>
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ export const PrimaryDashboardButton: React.FC<AddToDashboardCardProps> = ({
|
||||
// if user is on a project page
|
||||
if (projectMeta) {
|
||||
return (
|
||||
<Button asChild variant="default">
|
||||
<Button asChild className="rounded-full" variant="default">
|
||||
<Link
|
||||
className="gap-2"
|
||||
href={`/${contractInfo.chainSlug}/${contractAddress}`}
|
||||
|
||||
+12
-3
@@ -1,6 +1,7 @@
|
||||
import { getAddress, getContract, isAddress } from "thirdweb";
|
||||
import { localhost } from "thirdweb/chains";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { mapV4ChainToV5Chain } from "@/utils/map-chains";
|
||||
import { getUserThirdwebClient } from "../../../../../../../@/api/auth-token";
|
||||
import { fetchChainWithLocalOverrides } from "../../../../../../../@/utils/fetchChainWithLocalOverrides";
|
||||
@@ -18,13 +19,21 @@ export async function getContractPageParamsInfo(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// attempt to get the auth token
|
||||
// Create server client only if secret key is available
|
||||
if (!DASHBOARD_THIRDWEB_SECRET_KEY) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const serverClient = getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
});
|
||||
|
||||
const serverContract = getContract({
|
||||
address: contractAddress,
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
chain: mapV4ChainToV5Chain(chainMetadata),
|
||||
client: serverThirdwebClient,
|
||||
client: serverClient,
|
||||
});
|
||||
|
||||
const clientContract = getContract({
|
||||
|
||||
+1
-1
@@ -1,4 +1,5 @@
|
||||
/** biome-ignore-all lint/nursery/noNestedComponentDefinitions: FIXME */
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: FIXME */
|
||||
"use client";
|
||||
|
||||
import {
|
||||
@@ -320,7 +321,6 @@ export const NFTGetAllTable: React.FC<ContractOverviewNFTGetAllProps> = ({
|
||||
}}
|
||||
opacity={failedToLoad ? 0.3 : 1}
|
||||
pointerEvents={failedToLoad ? "none" : "auto"}
|
||||
// biome-ignore lint/a11y/useSemanticElements: FIXME
|
||||
role="group"
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { PublicPageConnectButton } from "./PublicPageConnectButton";
|
||||
|
||||
export function PageHeader(props: { containerClassName?: string }) {
|
||||
return (
|
||||
<div className="border-b border-dashed">
|
||||
<div className="border-b bg-card">
|
||||
<header
|
||||
className={cn(
|
||||
"container flex max-w-8xl justify-between py-3",
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { isOwnerSupported, owner } from "thirdweb/extensions/common";
|
||||
import {
|
||||
getRoleMember,
|
||||
isGetRoleAdminSupported,
|
||||
} from "thirdweb/extensions/permissions";
|
||||
|
||||
export async function getContractCreator(
|
||||
contract: ThirdwebContract,
|
||||
functionSelectors: string[],
|
||||
) {
|
||||
try {
|
||||
if (isOwnerSupported(functionSelectors)) {
|
||||
return await owner({
|
||||
contract,
|
||||
});
|
||||
}
|
||||
|
||||
if (isGetRoleAdminSupported(functionSelectors)) {
|
||||
return await getRoleMember({
|
||||
contract,
|
||||
index: BigInt(0),
|
||||
role: "admin",
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+36
-8
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { BadgeContainer } from "../../../../../../../../@/storybook/utils";
|
||||
import { BadgeContainer } from "@/storybook/utils";
|
||||
import { SupplyClaimedProgress } from "./supply-claimed-progress";
|
||||
|
||||
const meta = {
|
||||
@@ -18,28 +17,57 @@ export const Variants: Story = {
|
||||
function StoryVariants() {
|
||||
return (
|
||||
<div className="container max-w-md space-y-10 py-10">
|
||||
<BadgeContainer label="5.5 / Unlimited Supply">
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={5.5}
|
||||
totalSupplyTokens="unlimited"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 / Unlimited Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={maxUint256} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens="unlimited"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="500/1000 Supply">
|
||||
<SupplyClaimedProgress claimedSupply={500n} totalSupply={1000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={500}
|
||||
totalSupplyTokens={1000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="123.45/1000 Supply">
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={123.45}
|
||||
totalSupplyTokens={1000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10/1M Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={1000000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens={1000000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0/1M Supply">
|
||||
<SupplyClaimedProgress claimedSupply={0n} totalSupply={1000000n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={0}
|
||||
totalSupplyTokens={1000000}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10/10 Supply">
|
||||
<SupplyClaimedProgress claimedSupply={10n} totalSupply={10n} />
|
||||
<SupplyClaimedProgress
|
||||
claimedSupplyTokens={10}
|
||||
totalSupplyTokens={10}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 Supply - don't show anything">
|
||||
<SupplyClaimedProgress claimedSupply={0n} totalSupply={0n} />
|
||||
<SupplyClaimedProgress claimedSupplyTokens={0} totalSupplyTokens={0} />
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
+12
-13
@@ -1,19 +1,18 @@
|
||||
import { InfinityIcon } from "lucide-react";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { supplyFormatter } from "../nft/format";
|
||||
|
||||
export function SupplyClaimedProgress(props: {
|
||||
claimedSupply: bigint;
|
||||
totalSupply: bigint;
|
||||
claimedSupplyTokens: number;
|
||||
totalSupplyTokens: number | "unlimited";
|
||||
}) {
|
||||
// if total supply is unlimited
|
||||
if (props.totalSupply === maxUint256) {
|
||||
if (props.totalSupplyTokens === "unlimited") {
|
||||
return (
|
||||
<p className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-sm">Claimed Supply </span>
|
||||
<span className="flex items-center gap-1 font-bold text-sm">
|
||||
{supplyFormatter.format(props.claimedSupply)} /{" "}
|
||||
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
|
||||
<InfinityIcon aria-label="Unlimited" className="size-4" />
|
||||
</span>
|
||||
</p>
|
||||
@@ -21,29 +20,29 @@ export function SupplyClaimedProgress(props: {
|
||||
}
|
||||
|
||||
// if total supply is 0 - don't show anything
|
||||
if (props.totalSupply === 0n) {
|
||||
if (props.totalSupplyTokens === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// multiply by 10k to have precision up to 2 decimal places in percentage value
|
||||
const soldFractionTimes10KBigInt =
|
||||
(props.claimedSupply * 10000n) / props.totalSupply;
|
||||
|
||||
const soldPercentage = Number(soldFractionTimes10KBigInt) / 100;
|
||||
const soldPercentage =
|
||||
(props.claimedSupplyTokens / props.totalSupplyTokens) * 100;
|
||||
|
||||
const percentToShow = soldPercentage.toFixed(2);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">Supply Claimed</span>
|
||||
<span className="font-bold text-sm">
|
||||
{supplyFormatter.format(props.claimedSupply)} /{" "}
|
||||
{supplyFormatter.format(props.totalSupply)}
|
||||
{supplyFormatter.format(props.claimedSupplyTokens)} /{" "}
|
||||
{supplyFormatter.format(props.totalSupplyTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress className="h-2.5" value={soldPercentage} />
|
||||
<p className="font-medium text-muted-foreground text-xs">
|
||||
{soldPercentage === 0 && props.claimedSupply !== 0n && "~"}
|
||||
{soldPercentage.toFixed(2)}% Sold
|
||||
{percentToShow === "0.00" ? "~0.00%" : `${percentToShow}%`} Sold
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import "server-only";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
export type TokenPriceData = {
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
percent_change_24h: number;
|
||||
market_cap_usd: number;
|
||||
volume_24h_usd: number;
|
||||
volume_change_24h: number;
|
||||
holders: number;
|
||||
historical_prices: Array<{
|
||||
date: string;
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function getTokenPriceData(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
try {
|
||||
const url = new URL(
|
||||
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
|
||||
);
|
||||
|
||||
url.searchParams.set("include_historical_prices", "true");
|
||||
url.searchParams.set("chain_id", params.chainId.toString());
|
||||
url.searchParams.set("address", params.contractAddress);
|
||||
url.searchParams.set("include_holders", "true");
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error("Failed to fetch token price data", await res.text());
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const priceData = json.data[0] as TokenPriceData | undefined;
|
||||
|
||||
return priceData;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
+25
@@ -88,6 +88,25 @@ export const WithImageAndMultipleSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: mockTokenImage,
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
discord: mockSocialUrls.discord,
|
||||
github: mockSocialUrls.github,
|
||||
telegram: mockSocialUrls.telegram,
|
||||
twitter: mockSocialUrls.twitter,
|
||||
website: mockSocialUrls.website,
|
||||
},
|
||||
symbol: "SMPL",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithContractCreator: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: "0x1234567890123456789012345678901234567890",
|
||||
image: mockTokenImage,
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
@@ -105,6 +124,7 @@ export const WithBrokenImageAndSingleSocialUrl: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: "broken-image.png",
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
@@ -118,6 +138,7 @@ export const WithoutImageAndNoSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: undefined,
|
||||
name: "Sample Token",
|
||||
socialUrls: {},
|
||||
@@ -129,6 +150,7 @@ export const LongNameAndLotsOfSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: "https://thirdweb.com/chain-icons/ethereum.svg",
|
||||
name: "This is a very long token name that should wrap to multiple lines",
|
||||
socialUrls: {
|
||||
@@ -148,6 +170,7 @@ export const AllSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: "https://thirdweb.com/chain-icons/ethereum.svg",
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
@@ -171,6 +194,7 @@ export const InvalidSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: "https://thirdweb.com/chain-icons/ethereum.svg",
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
@@ -188,6 +212,7 @@ export const SomeSocialUrls: Story = {
|
||||
args: {
|
||||
chainMetadata: ethereumChainMetadata,
|
||||
clientContract: mockContract,
|
||||
contractCreator: null,
|
||||
image: "https://thirdweb.com/chain-icons/ethereum.svg",
|
||||
name: "Sample Token",
|
||||
socialUrls: {
|
||||
|
||||
+86
-37
@@ -1,7 +1,12 @@
|
||||
import { ExternalLinkIcon, GlobeIcon } from "lucide-react";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
GlobeIcon,
|
||||
Settings2Icon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import type { ThirdwebContract } from "thirdweb";
|
||||
import { type ThirdwebContract, ZERO_ADDRESS } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -42,6 +47,8 @@ export function ContractHeaderUI(props: {
|
||||
clientContract: ThirdwebContract;
|
||||
socialUrls: object;
|
||||
imageClassName?: string;
|
||||
contractCreator: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const socialUrls = useMemo(() => {
|
||||
const socialUrlsValue: { name: string; href: string }[] = [];
|
||||
@@ -62,41 +69,45 @@ export function ContractHeaderUI(props: {
|
||||
?.replace("Mainnet", "")
|
||||
.trim();
|
||||
|
||||
const explorersToShow = getExplorersToShow(props.chainMetadata);
|
||||
const validBlockExplorer = getExplorerToShow(props.chainMetadata);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-4 border-b border-dashed py-8 lg:flex-row lg:items-center">
|
||||
{props.image && (
|
||||
<Img
|
||||
className={cn(
|
||||
"size-20 shrink-0 rounded-full border bg-muted",
|
||||
props.imageClassName,
|
||||
)}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center font-bold text-3xl text-muted-foreground/80">
|
||||
{props.name[0]}
|
||||
</div>
|
||||
}
|
||||
src={
|
||||
props.image
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.clientContract.client,
|
||||
uri: props.image,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col lg:flex-row lg:items-center gap-5 py-6 relative",
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div className="flex">
|
||||
<div className="border p-1 rounded-full bg-card">
|
||||
<Img
|
||||
className={cn("size-28 shrink-0 rounded-full bg-muted")}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center font-bold text-5xl text-muted-foreground/50 capitalize">
|
||||
{props.name[0]}
|
||||
</div>
|
||||
}
|
||||
src={
|
||||
props.image
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: props.clientContract.client,
|
||||
uri: props.image,
|
||||
})
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2.5 flex-1">
|
||||
{/* top row */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col flex-wrap gap-3 lg:flex-row lg:items-center">
|
||||
<h1 className="line-clamp-2 font-bold text-2xl tracking-tight lg:text-3xl">
|
||||
<h1 className="font-semibold text-3xl tracking-tighter lg:text-5xl">
|
||||
{props.name}
|
||||
</h1>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2 ">
|
||||
<Link
|
||||
className="flex w-fit shrink-0 items-center gap-2 rounded-3xl border border-border bg-card px-2.5 py-1.5 hover:bg-accent"
|
||||
href={`/${props.chainMetadata.slug}`}
|
||||
@@ -111,6 +122,16 @@ export function ContractHeaderUI(props: {
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{props.contractCreator &&
|
||||
validBlockExplorer &&
|
||||
props.contractCreator !== ZERO_ADDRESS && (
|
||||
<SocialLink
|
||||
href={`${validBlockExplorer.url}/address/${props.contractCreator}`}
|
||||
name="Contract Owner"
|
||||
icon={UserIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{socialUrls
|
||||
.toSorted((a, b) => {
|
||||
const aIcon = platformToIcons[a.name.toLowerCase()];
|
||||
@@ -139,21 +160,46 @@ export function ContractHeaderUI(props: {
|
||||
</div>
|
||||
|
||||
{/* bottom row */}
|
||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||
<div className="flex flex-col items-end sm:flex-row flex-wrap sm:items-center gap-2 absolute top-6 right-0 sm:static w-1/2 sm:w-full">
|
||||
<CopyAddressButton
|
||||
address={props.clientContract.address}
|
||||
className="rounded-full bg-card px-2.5 py-1.5 text-xs"
|
||||
copyIconPosition="left"
|
||||
tooltip="Copy contract address"
|
||||
variant="outline"
|
||||
/>
|
||||
|
||||
{explorersToShow?.map((validBlockExplorer) => (
|
||||
<ToolTipLabel
|
||||
contentClassName="max-w-[300px]"
|
||||
label={
|
||||
<>
|
||||
View this contract in thirdweb dashboard to view contract
|
||||
management interface
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
className="rounded-full bg-card gap-1.5 text-xs py-1.5 px-2.5 h-auto"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Link
|
||||
href={`/team/~/~/contract/${props.chainMetadata.slug}/${props.clientContract.address}`}
|
||||
>
|
||||
<Settings2Icon className="size-3.5 text-muted-foreground" />
|
||||
Manage Contract
|
||||
</Link>
|
||||
</Button>
|
||||
</ToolTipLabel>
|
||||
|
||||
{validBlockExplorer && (
|
||||
<BadgeLink
|
||||
href={`${validBlockExplorer.url.endsWith("/") ? validBlockExplorer.url : `${validBlockExplorer.url}/`}address/${props.clientContract.address}`}
|
||||
key={validBlockExplorer.url}
|
||||
name={validBlockExplorer.name}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* TODO - render social links here */}
|
||||
</div>
|
||||
@@ -171,19 +217,22 @@ function isValidUrl(url: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function getExplorersToShow(chainMetadata: ChainMetadata) {
|
||||
const validBlockExplorers = chainMetadata.explorers
|
||||
?.filter((e) => e.standard === "EIP3091")
|
||||
?.slice(0, 2);
|
||||
function getExplorerToShow(chainMetadata: ChainMetadata) {
|
||||
const validBlockExplorers = chainMetadata.explorers?.filter(
|
||||
(e) => e.standard === "EIP3091",
|
||||
);
|
||||
|
||||
return validBlockExplorers?.slice(0, 1);
|
||||
return validBlockExplorers?.[0];
|
||||
}
|
||||
|
||||
function BadgeLink(props: { name: string; href: string }) {
|
||||
function BadgeLink(props: { name: string; href: string; className?: string }) {
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
className="!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize"
|
||||
className={cn(
|
||||
"!h-auto gap-2 rounded-full bg-card px-3 py-1.5 text-xs capitalize",
|
||||
props.className,
|
||||
)}
|
||||
variant="outline"
|
||||
>
|
||||
<Link href={props.href} rel="noopener noreferrer" target="_blank">
|
||||
|
||||
+26
-24
@@ -2,45 +2,47 @@
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import type { Chain, ThirdwebClient } from "thirdweb";
|
||||
import { PayEmbed } from "thirdweb/react";
|
||||
import { getSDKTheme } from "../../../../../../../../../@/utils/sdk-component-theme";
|
||||
import { BuyWidget } from "thirdweb/react";
|
||||
import {
|
||||
reportAssetBuyFailed,
|
||||
reportAssetBuySuccessful,
|
||||
} from "@/analytics/report";
|
||||
import { parseError } from "@/utils/errorParser";
|
||||
import { getSDKTheme } from "@/utils/sdk-component-theme";
|
||||
|
||||
export function BuyTokenEmbed(props: {
|
||||
client: ThirdwebClient;
|
||||
chain: Chain;
|
||||
tokenSymbol: string;
|
||||
tokenName: string;
|
||||
tokenAddress: string;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<PayEmbed
|
||||
<BuyWidget
|
||||
amount="1"
|
||||
chain={props.chain}
|
||||
className="!rounded-xl !w-full"
|
||||
client={props.client}
|
||||
connectOptions={{
|
||||
autoConnect: false,
|
||||
}}
|
||||
payOptions={{
|
||||
metadata: {
|
||||
name: `Buy ${props.tokenSymbol}`,
|
||||
},
|
||||
mode: "fund_wallet",
|
||||
prefillBuy: {
|
||||
allowEdits: {
|
||||
amount: true,
|
||||
chain: false,
|
||||
token: false,
|
||||
},
|
||||
amount: "1",
|
||||
chain: props.chain,
|
||||
token: {
|
||||
address: props.tokenAddress,
|
||||
name: props.tokenName,
|
||||
symbol: props.tokenSymbol,
|
||||
},
|
||||
},
|
||||
onError={(e) => {
|
||||
const errorMessage = parseError(e);
|
||||
reportAssetBuyFailed({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: "DropERC20",
|
||||
error: errorMessage,
|
||||
});
|
||||
}}
|
||||
onSuccess={() => {
|
||||
reportAssetBuySuccessful({
|
||||
assetType: "coin",
|
||||
chainId: props.chain.id,
|
||||
contractType: "DropERC20",
|
||||
});
|
||||
}}
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
tokenAddress={props.tokenAddress as `0x${string}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+63
-112
@@ -6,13 +6,11 @@ import { useMemo, useState } from "react";
|
||||
import { ThirdwebAreaChart } from "@/components/blocks/charts/area-chart";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SkeletonContainer } from "@/components/ui/skeleton";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTokenPriceData } from "../_hooks/useTokenPriceData";
|
||||
import type { TokenPriceData } from "../_apis/token-price-data";
|
||||
|
||||
function PriceChartUI(props: {
|
||||
isPending: boolean;
|
||||
showTimeOfDay: boolean;
|
||||
data: Array<{
|
||||
date: string;
|
||||
@@ -29,7 +27,7 @@ function PriceChartUI(props: {
|
||||
<ThirdwebAreaChart
|
||||
cardContentClassName="p-0"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3]"
|
||||
className="border-none bg-background p-0"
|
||||
className="border-none bg-transparent p-0"
|
||||
config={{
|
||||
price: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
@@ -38,7 +36,7 @@ function PriceChartUI(props: {
|
||||
}}
|
||||
data={data}
|
||||
hideLabel={false}
|
||||
isPending={props.isPending}
|
||||
isPending={false}
|
||||
toolTipLabelFormatter={getTooltipLabelFormatter(props.showTimeOfDay)}
|
||||
toolTipValueFormatter={(value) => {
|
||||
return tokenPriceUSDFormatter.format(value as number);
|
||||
@@ -49,7 +47,7 @@ function PriceChartUI(props: {
|
||||
|
||||
const tokenPriceUSDFormatter = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 10,
|
||||
maximumFractionDigits: 8,
|
||||
minimumFractionDigits: 0,
|
||||
notation: "compact",
|
||||
roundingMode: "halfEven",
|
||||
@@ -92,20 +90,14 @@ function getTooltipLabelFormatter(includeTimeOfDay: boolean) {
|
||||
export function TokenStats(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
tokenPriceData: TokenPriceData;
|
||||
}) {
|
||||
const tokenPriceQuery = useTokenPriceData(params);
|
||||
const [interval, setInterval] = useState<Interval>("max");
|
||||
|
||||
const tokenPriceData = tokenPriceQuery.data;
|
||||
|
||||
const filteredHistoricalPrices = useMemo(() => {
|
||||
const currentDate = new Date();
|
||||
|
||||
if (tokenPriceData?.type === "no-data") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return tokenPriceData?.data?.historical_prices.filter((item) => {
|
||||
return params.tokenPriceData.historical_prices.filter((item) => {
|
||||
const date = new Date(item.date);
|
||||
const maxDiff =
|
||||
interval === "24h"
|
||||
@@ -120,106 +112,75 @@ export function TokenStats(params: {
|
||||
|
||||
return differenceInCalendarDays(currentDate, date) <= maxDiff;
|
||||
});
|
||||
}, [tokenPriceData, interval]);
|
||||
}, [params.tokenPriceData, interval]);
|
||||
|
||||
const priceUsd =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.price_usd;
|
||||
const priceUsd = params.tokenPriceData.price_usd;
|
||||
const percentChange24h = params.tokenPriceData.percent_change_24h;
|
||||
const marketCap = params.tokenPriceData.market_cap_usd;
|
||||
const holders = params.tokenPriceData.holders;
|
||||
|
||||
const percentChange24h =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.percent_change_24h;
|
||||
const formattedAbsChange = percentChangeFormatter.format(
|
||||
Math.abs(percentChange24h),
|
||||
);
|
||||
|
||||
const marketCap =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.market_cap_usd;
|
||||
const isAlmostZeroChange =
|
||||
formattedAbsChange === percentChangeFormatter.format(0);
|
||||
|
||||
const holders =
|
||||
tokenPriceData?.type === "no-data" || tokenPriceQuery.isError
|
||||
? "N/A"
|
||||
: tokenPriceData?.data?.holders;
|
||||
const formattedPriceUSD = tokenPriceUSDFormatter.format(priceUsd);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-card rounded-lg border">
|
||||
{/* price and change */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between p-4 lg:p-6">
|
||||
<div>
|
||||
<p className="mb-1 text-muted-foreground text-sm">Current Price</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonContainer
|
||||
loadedData={priceUsd}
|
||||
render={(v) => {
|
||||
return (
|
||||
<p className="font-bold text-4xl tracking-tight">
|
||||
{typeof v === "number"
|
||||
? tokenPriceUSDFormatter.format(v)
|
||||
: v}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
skeletonData={10.0001}
|
||||
/>
|
||||
<SkeletonContainer
|
||||
loadedData={percentChange24h}
|
||||
render={(data) => {
|
||||
if (typeof data === "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formattedAbsChange = percentChangeFormatter.format(
|
||||
Math.abs(data),
|
||||
);
|
||||
|
||||
const isAlmostZero =
|
||||
formattedAbsChange === percentChangeFormatter.format(0);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className="gap-2 text-sm"
|
||||
variant={
|
||||
isAlmostZero
|
||||
? "default"
|
||||
: data > 0
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isAlmostZero ? null : data > 0 ? (
|
||||
<ArrowUpIcon className="size-3" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{isAlmostZero ? "~0%" : `${formattedAbsChange}%`}
|
||||
</span>
|
||||
</div>
|
||||
<span>(1d)</span>
|
||||
</Badge>
|
||||
);
|
||||
}}
|
||||
skeletonData={0.001}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"font-bold text-3xl lg:text-4xl tracking-tight",
|
||||
formattedPriceUSD.length > 8 && "text-2xl lg:text-4xl",
|
||||
)}
|
||||
>
|
||||
{typeof priceUsd === "number" ? formattedPriceUSD : priceUsd}
|
||||
</p>
|
||||
<Badge
|
||||
className="gap-2 lg:text-sm text-xs px-1.5"
|
||||
variant={
|
||||
isAlmostZeroChange
|
||||
? "default"
|
||||
: percentChange24h > 0
|
||||
? "success"
|
||||
: "destructive"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{isAlmostZeroChange ? null : percentChange24h > 0 ? (
|
||||
<ArrowUpIcon className="size-3" />
|
||||
) : (
|
||||
<ArrowDownIcon className="size-3" />
|
||||
)}
|
||||
<span>
|
||||
{isAlmostZeroChange ? "~0%" : `${formattedAbsChange}%`}
|
||||
</span>
|
||||
</div>
|
||||
<span>(1d)</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntervalSelector interval={interval} setInterval={setInterval} />
|
||||
</div>
|
||||
|
||||
<div className="h-4" />
|
||||
<PriceChartUI
|
||||
data={filteredHistoricalPrices || []}
|
||||
isPending={tokenPriceQuery.isPending}
|
||||
showTimeOfDay={interval === "24h"}
|
||||
/>
|
||||
<div className="px-0">
|
||||
<PriceChartUI
|
||||
data={filteredHistoricalPrices || []}
|
||||
showTimeOfDay={interval === "24h"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex gap-6 border-y border-dashed py-4 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:border-dashed [&>*:not(:first-child)]:pl-5 [&>*]:grow">
|
||||
<div className="px-4 lg:px-6 py-4 mt-6 border-t border-dashed flex gap-6 [&>*:not(:first-child)]:border-l [&>*:not(:first-child)]:pl-5 [&>*]:grow">
|
||||
<TokenStat
|
||||
label="Market Cap"
|
||||
skeletonData={"1000000000"}
|
||||
tooltip="Market capitalization is the total value of all tokens in circulation, calculated by multiplying the current price by the circulating supply"
|
||||
value={
|
||||
typeof marketCap === "number"
|
||||
@@ -230,7 +191,6 @@ export function TokenStats(params: {
|
||||
|
||||
<TokenStat
|
||||
label="Number of Holders"
|
||||
skeletonData={"1000000000"}
|
||||
tooltip="The total number of unique wallet addresses that currently hold this token on the blockchain"
|
||||
value={
|
||||
// show 0 value as N/A
|
||||
@@ -246,9 +206,8 @@ export function TokenStats(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function TokenStat<T extends string | number>(props: {
|
||||
value: T | undefined;
|
||||
skeletonData: T;
|
||||
function TokenStat(props: {
|
||||
value: string | number;
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}) {
|
||||
@@ -261,17 +220,9 @@ function TokenStat<T extends string | number>(props: {
|
||||
</ToolTipLabel>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<SkeletonContainer
|
||||
loadedData={props.value}
|
||||
render={(v) => {
|
||||
return (
|
||||
<p className="font-semibold text-foreground text-xl tracking-tight">
|
||||
{v}
|
||||
</p>
|
||||
);
|
||||
}}
|
||||
skeletonData={props.skeletonData}
|
||||
/>
|
||||
<p className="font-semibold text-foreground text-xl tracking-tight">
|
||||
{props.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -296,7 +247,7 @@ function IntervalSelector(props: {
|
||||
{Object.entries(intervals).map(([key, value]) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
"rounded-full px-2.5 py-1.5 h-auto bg-background text-xs lg:text-sm",
|
||||
props.interval === key && "border-active-border bg-accent",
|
||||
)}
|
||||
key={key}
|
||||
|
||||
+21
-23
@@ -47,8 +47,8 @@ function RecentTransfersUI(props: {
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<h2 className="font-semibold text-xl tracking-tight">
|
||||
<div className="p-4 lg:p-6 bg-card border rounded-b-none border-b-0 rounded-lg">
|
||||
<h2 className="font-semibold text-2xl tracking-tight mb-0.5">
|
||||
Recent Transfers
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
@@ -57,7 +57,7 @@ function RecentTransfersUI(props: {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<TableContainer className="rounded-b-none">
|
||||
<TableContainer className="rounded-b-none rounded-t-none">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -159,7 +159,7 @@ function RecentTransfersUI(props: {
|
||||
|
||||
<div className="flex items-center justify-end gap-3 rounded-b-lg border-x border-b bg-card px-6 py-5">
|
||||
<Button
|
||||
className="gap-1.5 bg-background"
|
||||
className="gap-1.5 bg-background rounded-full"
|
||||
disabled={props.page === 0 || props.isPending}
|
||||
onClick={() => props.setPage(props.page - 1)}
|
||||
size="sm"
|
||||
@@ -169,7 +169,7 @@ function RecentTransfersUI(props: {
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
className="gap-1.5 bg-background"
|
||||
className="gap-1.5 bg-background rounded-full"
|
||||
disabled={props.isPending || props.data.length < props.rowsPerPage}
|
||||
onClick={() => props.setPage(props.page + 1)}
|
||||
size="sm"
|
||||
@@ -222,23 +222,21 @@ export function RecentTransfers(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RecentTransfersUI
|
||||
client={props.clientContract.client}
|
||||
data={tokenQuery.data ?? []}
|
||||
explorerUrl={
|
||||
props.chainMetadata.explorers?.[0]?.url ||
|
||||
`https://thirdweb.com/${props.chainMetadata.slug}`
|
||||
}
|
||||
isPending={tokenQuery.isPending}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
setPage={setPage}
|
||||
tokenMetadata={{
|
||||
decimals: props.decimals,
|
||||
symbol: props.tokenSymbol,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<RecentTransfersUI
|
||||
client={props.clientContract.client}
|
||||
data={tokenQuery.data ?? []}
|
||||
explorerUrl={
|
||||
props.chainMetadata.explorers?.[0]?.url ||
|
||||
`https://thirdweb.com/${props.chainMetadata.slug}`
|
||||
}
|
||||
isPending={tokenQuery.isPending}
|
||||
page={page}
|
||||
rowsPerPage={rowsPerPage}
|
||||
setPage={setPage}
|
||||
tokenMetadata={{
|
||||
decimals: props.decimals,
|
||||
symbol: props.tokenSymbol,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-5
@@ -26,7 +26,7 @@ import {
|
||||
getApprovalForTransaction,
|
||||
} from "thirdweb/extensions/erc20";
|
||||
import { useActiveAccount, useSendTransaction } from "thirdweb/react";
|
||||
import { getClaimParams } from "thirdweb/utils";
|
||||
import { getClaimParams, maxUint256 } from "thirdweb/utils";
|
||||
import {
|
||||
reportAssetBuyFailed,
|
||||
reportAssetBuySuccessful,
|
||||
@@ -325,12 +325,19 @@ export function TokenDropClaim(props: {
|
||||
<div className="h-4" />
|
||||
|
||||
<SupplyClaimedProgress
|
||||
claimedSupply={BigInt(
|
||||
claimedSupplyTokens={Number(
|
||||
toTokens(props.claimCondition.supplyClaimed, props.decimals),
|
||||
)}
|
||||
totalSupply={BigInt(
|
||||
toTokens(props.claimCondition.maxClaimableSupply, props.decimals),
|
||||
)}
|
||||
totalSupplyTokens={
|
||||
props.claimCondition.maxClaimableSupply === maxUint256
|
||||
? "unlimited"
|
||||
: Number(
|
||||
toTokens(
|
||||
props.claimCondition.maxClaimableSupply,
|
||||
props.decimals,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="h-4" />
|
||||
|
||||
+33
-33
@@ -29,38 +29,38 @@ export function ContractAnalyticsOverview(props: {
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col">
|
||||
<h2 className="font-semibold text-xl tracking-tight">Analytics</h2>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
View trends in unique wallets, transactions, and events over time for
|
||||
this contract
|
||||
</p>
|
||||
</div>
|
||||
<ThirdwebAreaChart
|
||||
cardContentClassName="p-0"
|
||||
chartClassName="aspect-[1.5] lg:aspect-[3]"
|
||||
className="border-none bg-background p-0"
|
||||
config={{
|
||||
events: {
|
||||
color: "hsl(var(--chart-3))",
|
||||
label: "Events",
|
||||
},
|
||||
transactions: {
|
||||
color: "hsl(var(--chart-2))",
|
||||
label: "Transactions",
|
||||
},
|
||||
wallets: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Unique Wallets",
|
||||
},
|
||||
}}
|
||||
data={data || []}
|
||||
hideLabel={false}
|
||||
isPending={isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
|
||||
/>
|
||||
</div>
|
||||
<ThirdwebAreaChart
|
||||
chartClassName="aspect-[1.2] lg:aspect-[3]"
|
||||
cardContentClassName="px-0 pb-0"
|
||||
margin={{
|
||||
bottom: 20,
|
||||
}}
|
||||
header={{
|
||||
headerClassName: "p-4 lg:p-6",
|
||||
title: "Analytics",
|
||||
titleClassName: "font-semibold text-2xl mb-0.5 tracking-tight",
|
||||
description:
|
||||
"View trends of transactions, events and unique wallets interacting with this contract over time",
|
||||
}}
|
||||
config={{
|
||||
events: {
|
||||
color: "hsl(var(--chart-5))",
|
||||
label: "Events",
|
||||
},
|
||||
transactions: {
|
||||
color: "hsl(var(--chart-8))",
|
||||
label: "Transactions",
|
||||
},
|
||||
wallets: {
|
||||
color: "hsl(var(--chart-1))",
|
||||
label: "Unique Wallets",
|
||||
},
|
||||
}}
|
||||
data={data || []}
|
||||
hideLabel={false}
|
||||
isPending={isPending}
|
||||
showLegend
|
||||
toolTipLabelFormatter={toolTipLabelFormatterWithPrecision(precision)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
|
||||
type TokenPriceData = {
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
percent_change_24h: number;
|
||||
market_cap_usd: number;
|
||||
volume_24h_usd: number;
|
||||
volume_change_24h: number;
|
||||
holders: number;
|
||||
historical_prices: Array<{
|
||||
date: string;
|
||||
price_usd: number;
|
||||
price_usd_cents: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function useTokenPriceData(params: {
|
||||
chainId: number;
|
||||
contractAddress: string;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryFn: async () => {
|
||||
const url = new URL(
|
||||
`https://insight.${isProd ? "thirdweb" : "thirdweb-dev"}.com/v1/tokens/price`,
|
||||
);
|
||||
|
||||
url.searchParams.set("include_historical_prices", "true");
|
||||
url.searchParams.set("chain_id", params.chainId.toString());
|
||||
url.searchParams.set("address", params.contractAddress);
|
||||
url.searchParams.set("include_holders", "true");
|
||||
url.searchParams.set("clientId", NEXT_PUBLIC_DASHBOARD_CLIENT_ID);
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
const priceData = json.data[0] as TokenPriceData | undefined;
|
||||
return priceData
|
||||
? {
|
||||
data: priceData,
|
||||
type: "data-found" as const,
|
||||
}
|
||||
: { type: "no-data" as const };
|
||||
},
|
||||
queryKey: ["token-price-chart", params.chainId, params.contractAddress],
|
||||
refetchInterval: 5000,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
retryOnMount: false,
|
||||
});
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import "server-only";
|
||||
import { isProd } from "@/constants/env-utils";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
export async function fetchTokenInfoFromBridge(params: {
|
||||
chainId: number;
|
||||
tokenAddress: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`https://bridge.${isProd ? "thirdweb.com" : "thirdweb-dev.com"}/v1/tokens?chainId=${params.chainId}&tokenAddress=${params.tokenAddress}`,
|
||||
{
|
||||
headers: {
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(
|
||||
`Failed to fetch token info from bridge: ${await res.text()}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
data: Array<{
|
||||
iconUri: string;
|
||||
address: string;
|
||||
decimals: number;
|
||||
name: string;
|
||||
symbol: string;
|
||||
priceUsd: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
return data.data[0];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+101
-63
@@ -2,13 +2,18 @@ import type { ThirdwebContract } from "thirdweb";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { getContractMetadata } from "thirdweb/extensions/common";
|
||||
import { decimals, getActiveClaimCondition } from "thirdweb/extensions/erc20";
|
||||
import { GridPattern } from "@/components/ui/background-patterns";
|
||||
import { resolveFunctionSelectors } from "@/lib/selectors";
|
||||
import { getContractCreator } from "../_components/getContractCreator";
|
||||
import { PageHeader } from "../_components/PageHeader";
|
||||
import { getTokenPriceData } from "./_apis/token-price-data";
|
||||
import { ContractHeaderUI } from "./_components/ContractHeader";
|
||||
import { TokenDropClaim } from "./_components/claim-tokens/claim-tokens-ui";
|
||||
import { ContractAnalyticsOverview } from "./_components/contract-analytics/contract-analytics";
|
||||
import { BuyTokenEmbed } from "./_components/PayEmbedSection";
|
||||
import { TokenStats } from "./_components/PriceChart";
|
||||
import { RecentTransfers } from "./_components/RecentTransfers";
|
||||
import { fetchTokenInfoFromBridge } from "./_utils/fetch-coin-info";
|
||||
import { getCurrencyMeta } from "./_utils/getCurrencyMeta";
|
||||
|
||||
export async function ERC20PublicPage(props: {
|
||||
@@ -16,25 +21,48 @@ export async function ERC20PublicPage(props: {
|
||||
clientContract: ThirdwebContract;
|
||||
chainMetadata: ChainMetadata;
|
||||
}) {
|
||||
const [contractMetadata, activeClaimCondition, tokenDecimals] =
|
||||
await Promise.all([
|
||||
getContractMetadata({
|
||||
contract: props.serverContract,
|
||||
}),
|
||||
getActiveClaimConditionWithErrorHandler(props.serverContract),
|
||||
decimals({
|
||||
contract: props.serverContract,
|
||||
}),
|
||||
]);
|
||||
const [
|
||||
contractMetadata,
|
||||
activeClaimCondition,
|
||||
tokenDecimals,
|
||||
tokenInfo,
|
||||
functionSelectors,
|
||||
tokenPriceData,
|
||||
] = await Promise.all([
|
||||
getContractMetadata({
|
||||
contract: props.serverContract,
|
||||
}),
|
||||
getActiveClaimConditionWithErrorHandler(props.serverContract),
|
||||
decimals({
|
||||
contract: props.serverContract,
|
||||
}),
|
||||
fetchTokenInfoFromBridge({
|
||||
chainId: props.serverContract.chain.id,
|
||||
clientId: props.clientContract.client.clientId,
|
||||
tokenAddress: props.serverContract.address,
|
||||
}),
|
||||
resolveFunctionSelectors(props.serverContract),
|
||||
getTokenPriceData({
|
||||
chainId: props.serverContract.chain.id,
|
||||
contractAddress: props.serverContract.address,
|
||||
}),
|
||||
]);
|
||||
|
||||
const claimConditionCurrencyMeta = activeClaimCondition
|
||||
? await getCurrencyMeta({
|
||||
chain: props.serverContract.chain,
|
||||
chainMetadata: props.chainMetadata,
|
||||
client: props.serverContract.client,
|
||||
currencyAddress: activeClaimCondition.currency,
|
||||
}).catch(() => undefined)
|
||||
: undefined;
|
||||
if (!contractMetadata.image && tokenInfo) {
|
||||
contractMetadata.image = tokenInfo.iconUri;
|
||||
}
|
||||
|
||||
const [contractCreator, claimConditionCurrencyMeta] = await Promise.all([
|
||||
getContractCreator(props.serverContract, functionSelectors),
|
||||
activeClaimCondition
|
||||
? getCurrencyMeta({
|
||||
chain: props.serverContract.chain,
|
||||
chainMetadata: props.chainMetadata,
|
||||
client: props.serverContract.client,
|
||||
currencyAddress: activeClaimCondition.currency,
|
||||
}).catch(() => undefined)
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
const buyEmbed = (
|
||||
<BuyEmbed
|
||||
@@ -57,61 +85,73 @@ export async function ERC20PublicPage(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<PageHeader />
|
||||
<div className="container flex max-w-8xl grow flex-col">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
image={contractMetadata.image}
|
||||
name={contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof contractMetadata.social_urls === "object" &&
|
||||
contractMetadata.social_urls !== null
|
||||
? contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={contractMetadata.symbol}
|
||||
/>
|
||||
<PageHeader containerClassName="max-w-5xl" />
|
||||
|
||||
<div className="h-6" />
|
||||
<div className="border-b">
|
||||
<div className="container max-w-5xl">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={contractCreator}
|
||||
image={contractMetadata.image}
|
||||
name={contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof contractMetadata.social_urls === "object" &&
|
||||
contractMetadata.social_urls !== null
|
||||
? contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={contractMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-8 pb-20 xl:flex-row">
|
||||
<div className="flex grow flex-col gap-8 overflow-hidden">
|
||||
{activeClaimCondition ? (
|
||||
<div className="border-b border-dashed pb-6">
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="container flex max-w-5xl grow flex-col pt-8 pb-10">
|
||||
<div className="flex grow flex-col gap-8">
|
||||
<div className="sm:flex sm:justify-center w-full sm:border sm:border-dashed sm:bg-accent/20 sm:py-12 rounded-lg overflow-hidden relative">
|
||||
<GridPattern
|
||||
width={30}
|
||||
height={30}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray={"4 2"}
|
||||
className="text-border dark:text-border/70"
|
||||
style={{
|
||||
maskImage:
|
||||
"linear-gradient(to bottom right,white,transparent,transparent)",
|
||||
}}
|
||||
/>
|
||||
<div className="sm:w-[420px] z-10">{buyEmbed}</div>
|
||||
</div>
|
||||
|
||||
{tokenPriceData ? (
|
||||
<>
|
||||
<TokenStats
|
||||
chainId={props.chainMetadata.chainId}
|
||||
contractAddress={props.clientContract.address}
|
||||
tokenPriceData={tokenPriceData}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="xl:hidden">{buyEmbed}</div>
|
||||
|
||||
<RecentTransfers
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
decimals={tokenDecimals}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
|
||||
{!activeClaimCondition && (
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden xl:block xl:w-96">
|
||||
<div className="-mt-6 sticky top-0 pt-6">{buyEmbed}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ContractAnalyticsOverview
|
||||
chainId={props.chainMetadata.chainId}
|
||||
chainSlug={props.chainMetadata.slug}
|
||||
contractAddress={props.clientContract.address}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RecentTransfers
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
decimals={tokenDecimals}
|
||||
tokenSymbol={contractMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,8 +181,6 @@ function BuyEmbed(props: {
|
||||
chain={props.clientContract.chain}
|
||||
client={props.clientContract.client}
|
||||
tokenAddress={props.clientContract.address}
|
||||
tokenName={props.tokenName}
|
||||
tokenSymbol={props.tokenSymbol}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+23
-18
@@ -12,29 +12,34 @@ export function NFTPublicPageLayout(props: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
children: React.ReactNode;
|
||||
contractCreator: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<PageHeader containerClassName="max-w-8xl" />
|
||||
<div className="border-b">
|
||||
<div className="container max-w-8xl">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={props.contractCreator}
|
||||
image={
|
||||
typeof props.contractMetadata.image === "string"
|
||||
? props.contractMetadata.image
|
||||
: undefined
|
||||
}
|
||||
name={props.contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof props.contractMetadata.social_urls === "object" &&
|
||||
props.contractMetadata.social_urls !== null
|
||||
? props.contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={props.contractMetadata.symbol}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="container flex max-w-8xl grow flex-col">
|
||||
<ContractHeaderUI
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
image={
|
||||
typeof props.contractMetadata.image === "string"
|
||||
? props.contractMetadata.image
|
||||
: undefined
|
||||
}
|
||||
imageClassName="rounded-lg"
|
||||
name={props.contractMetadata.name}
|
||||
socialUrls={
|
||||
typeof props.contractMetadata.social_urls === "object" &&
|
||||
props.contractMetadata.social_urls !== null
|
||||
? props.contractMetadata.social_urls
|
||||
: {}
|
||||
}
|
||||
symbol={props.contractMetadata.symbol}
|
||||
/>
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+7
-3
@@ -6,6 +6,7 @@ import { ResponsiveLayout } from "@/components/blocks/Responsive";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { resolveFunctionSelectors } from "@/lib/selectors";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getContractCreator } from "../_components/getContractCreator";
|
||||
import { NFTPublicPageLayout } from "./nft-page-layout";
|
||||
import {
|
||||
BuyNFTDropCardServer,
|
||||
@@ -35,15 +36,17 @@ export async function NFTPublicPage(props: {
|
||||
}),
|
||||
]);
|
||||
|
||||
const nftDropClaimParams =
|
||||
const [contractCreator, nftDropClaimParams] = await Promise.all([
|
||||
getContractCreator(props.serverContract, functionSelectors),
|
||||
props.type === "erc721"
|
||||
? await getNFTDropClaimParams({
|
||||
? getNFTDropClaimParams({
|
||||
chainMetadata: props.chainMetadata,
|
||||
functionSelectors,
|
||||
serverContract: props.serverContract,
|
||||
totalNFTCount,
|
||||
})
|
||||
: undefined;
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
const _isTokenByIndexSupported =
|
||||
props.type === "erc721" && isTokenByIndexSupported(functionSelectors);
|
||||
@@ -101,6 +104,7 @@ export async function NFTPublicPage(props: {
|
||||
<NFTPublicPageLayout
|
||||
chainMetadata={props.chainMetadata}
|
||||
clientContract={props.clientContract}
|
||||
contractCreator={contractCreator}
|
||||
contractMetadata={contractMetadata}
|
||||
>
|
||||
<ResponsiveLayout
|
||||
|
||||
+7
-2
@@ -10,6 +10,7 @@ import type { ChainMetadata } from "thirdweb/chains";
|
||||
import { getApprovalForTransaction } from "thirdweb/extensions/erc20";
|
||||
import { claimTo } from "thirdweb/extensions/erc1155";
|
||||
import { useActiveAccount, useSendAndConfirmTransaction } from "thirdweb/react";
|
||||
import { maxUint256 } from "thirdweb/utils";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
reportAssetBuyFailed,
|
||||
@@ -225,8 +226,12 @@ export function BuyEditionDrop(props: BuyEditionDropProps) {
|
||||
|
||||
{claimCondition.data ? (
|
||||
<SupplyClaimedProgress
|
||||
claimedSupply={claimCondition.data.supplyClaimed}
|
||||
totalSupply={claimCondition.data.maxClaimableSupply}
|
||||
claimedSupplyTokens={Number(claimCondition.data.supplyClaimed)}
|
||||
totalSupplyTokens={
|
||||
claimCondition.data.maxClaimableSupply === maxUint256
|
||||
? "unlimited"
|
||||
: Number(claimCondition.data.maxClaimableSupply)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className="h-[62px] w-full" />
|
||||
|
||||
+4
-2
@@ -231,8 +231,10 @@ export function BuyNFTDropUI(props: BuyNFTDropUIProps) {
|
||||
)}
|
||||
|
||||
<SupplyClaimedProgress
|
||||
claimedSupply={BigInt(props.totalNFTs) - props.totalUnclaimedSupply}
|
||||
totalSupply={BigInt(props.totalNFTs)}
|
||||
claimedSupplyTokens={Number(
|
||||
BigInt(props.totalNFTs) - props.totalUnclaimedSupply,
|
||||
)}
|
||||
totalSupplyTokens={Number(props.totalNFTs)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5 rounded-lg bg-muted/50 p-3">
|
||||
|
||||
+1
-1
@@ -1,3 +1,4 @@
|
||||
/** biome-ignore-all lint/a11y/useSemanticElements: EXPECTED */
|
||||
"use client";
|
||||
|
||||
import { CheckCheckIcon } from "lucide-react";
|
||||
@@ -210,7 +211,6 @@ function NFTCard(props: {
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
// biome-ignore lint/a11y/useSemanticElements: FIXME
|
||||
role="button"
|
||||
tabIndex={props.onClick ? 0 : undefined}
|
||||
>
|
||||
|
||||
+84
-86
@@ -43,94 +43,92 @@ export const TokenAirdropForm: React.FC<TokenAirdropFormProps> = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pt-3">
|
||||
<form
|
||||
onSubmit={handleSubmit(async (data) => {
|
||||
try {
|
||||
const tx = transferBatch({
|
||||
batch: data.addresses
|
||||
.filter((address) => address.quantity !== undefined)
|
||||
.map((address) => ({
|
||||
amount: address.quantity,
|
||||
to: address.address,
|
||||
})),
|
||||
contract,
|
||||
});
|
||||
await sendTransaction.mutateAsync(tx, {
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Close the sheet/modal on success
|
||||
if (toggle) {
|
||||
toggle(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
airdropNotifications.onSuccess();
|
||||
} catch (err) {
|
||||
airdropNotifications.onError(err);
|
||||
console.error(err);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className="mb-3 flex w-full flex-col gap-6 md:flex-row">
|
||||
{airdropFormOpen ? (
|
||||
<AirdropUpload
|
||||
client={contract.client}
|
||||
onClose={() => setAirdropFormOpen(false)}
|
||||
setAirdrop={(value) =>
|
||||
setValue("addresses", value, { shouldDirty: true })
|
||||
<div className="pt-3">
|
||||
<form
|
||||
onSubmit={handleSubmit(async (data) => {
|
||||
try {
|
||||
const tx = transferBatch({
|
||||
batch: data.addresses
|
||||
.filter((address) => address.quantity !== undefined)
|
||||
.map((address) => ({
|
||||
amount: address.quantity,
|
||||
to: address.address,
|
||||
})),
|
||||
contract,
|
||||
});
|
||||
await sendTransaction.mutateAsync(tx, {
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Close the sheet/modal on success
|
||||
if (toggle) {
|
||||
toggle(false);
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
onClick={() => setAirdropFormOpen(true)}
|
||||
rightIcon={<UploadIcon className="size-4" />}
|
||||
>
|
||||
Upload addresses
|
||||
</Button>
|
||||
{addresses.length > 0 && (
|
||||
<div className="flex flex-row items-center justify-center gap-2 text-green-500">
|
||||
<CircleCheckIcon className="text-green-500" size={16} />
|
||||
<Text color="inherit" size="body.sm">
|
||||
<strong>{addresses.length} addresses</strong> ready to be
|
||||
airdropped
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{addresses?.length > 0 && !airdropFormOpen && (
|
||||
<>
|
||||
{estimateGasCost && (
|
||||
<Text>
|
||||
This transaction requires at least {estimateGasCost} gas.
|
||||
Since each chain has a different gas limit, please split this
|
||||
operation into multiple transactions if necessary. Usually
|
||||
under 10M gas is safe.
|
||||
</Text>
|
||||
)}
|
||||
<TransactionButton
|
||||
className="self-end"
|
||||
client={contract.client}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendTransaction.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
},
|
||||
});
|
||||
airdropNotifications.onSuccess();
|
||||
} catch (err) {
|
||||
airdropNotifications.onError(err);
|
||||
console.error(err);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div className="mb-3 flex w-full flex-col gap-6 md:flex-row">
|
||||
{airdropFormOpen ? (
|
||||
<AirdropUpload
|
||||
client={contract.client}
|
||||
onClose={() => setAirdropFormOpen(false)}
|
||||
setAirdrop={(value) =>
|
||||
setValue("addresses", value, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
borderRadius="md"
|
||||
colorScheme="primary"
|
||||
onClick={() => setAirdropFormOpen(true)}
|
||||
rightIcon={<UploadIcon className="size-4" />}
|
||||
>
|
||||
Airdrop
|
||||
</TransactionButton>
|
||||
</>
|
||||
Upload addresses
|
||||
</Button>
|
||||
{addresses.length > 0 && (
|
||||
<div className="flex flex-row items-center justify-center gap-2 text-green-500">
|
||||
<CircleCheckIcon className="text-green-500" size={16} />
|
||||
<Text color="inherit" size="body.sm">
|
||||
<strong>{addresses.length} addresses</strong> ready to be
|
||||
airdropped
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
{addresses?.length > 0 && !airdropFormOpen && (
|
||||
<>
|
||||
{estimateGasCost && (
|
||||
<Text>
|
||||
This transaction requires at least {estimateGasCost} gas. Since
|
||||
each chain has a different gas limit, please split this
|
||||
operation into multiple transactions if necessary. Usually under
|
||||
10M gas is safe.
|
||||
</Text>
|
||||
)}
|
||||
<TransactionButton
|
||||
className="self-end"
|
||||
client={contract.client}
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPending={sendTransaction.isPending}
|
||||
transactionCount={1}
|
||||
txChainID={contract.chain.id}
|
||||
type="submit"
|
||||
>
|
||||
Airdrop
|
||||
</TransactionButton>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import "server-only";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { getConfiguredThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { resolveSchemeWithErrorHandler } from "@/utils/resolveSchemeWithErrorHandler";
|
||||
import { fallbackChainIcon } from "../../../../../../@/utils/chain-icons";
|
||||
@@ -13,10 +13,16 @@ export async function ChainIcon(props: {
|
||||
if (props.iconUrl) {
|
||||
let imageLink = fallbackChainIcon;
|
||||
|
||||
const resolved = resolveSchemeWithErrorHandler({
|
||||
client: serverThirdwebClient,
|
||||
uri: props.iconUrl,
|
||||
});
|
||||
// Only resolve if we have a secret key available
|
||||
const resolved = DASHBOARD_THIRDWEB_SECRET_KEY
|
||||
? resolveSchemeWithErrorHandler({
|
||||
client: getConfiguredThirdwebClient({
|
||||
secretKey: DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
teamId: undefined,
|
||||
}),
|
||||
uri: props.iconUrl,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (resolved) {
|
||||
// check if it loads or not
|
||||
|
||||
@@ -10,18 +10,18 @@ import type { ChainSupportedService } from "@/types/chain";
|
||||
|
||||
export const products = [
|
||||
{
|
||||
description: "Pre-built contracts & deployment tools",
|
||||
description: "Create, deploy and manage smart contracts",
|
||||
icon: ContractIcon,
|
||||
id: "contracts",
|
||||
link: "https://thirdweb.com/explore",
|
||||
name: "Contracts",
|
||||
},
|
||||
{
|
||||
description: "Client-side SDKs for wallets and blockchain interactions",
|
||||
description: "Create and manage crypto wallets",
|
||||
icon: ConnectSDKIcon,
|
||||
id: "connect-sdk",
|
||||
link: "https://thirdweb.com/connect",
|
||||
name: "Connect SDK",
|
||||
name: "Wallets",
|
||||
},
|
||||
{
|
||||
description: "Performant and scalable RPC service",
|
||||
@@ -35,24 +35,24 @@ export const products = [
|
||||
icon: EngineIcon,
|
||||
id: "engine",
|
||||
link: "https://thirdweb.com/engine",
|
||||
name: "Engine",
|
||||
name: "Transactions",
|
||||
},
|
||||
{
|
||||
description: "Account factory contracts, bundler & paymaster",
|
||||
description: "Enable gas sponsorship for seamless transactions",
|
||||
icon: SmartAccountIcon,
|
||||
id: "account-abstraction",
|
||||
link: "https://portal.thirdweb.com/connect/account-abstraction/overview",
|
||||
name: "Account Abstraction",
|
||||
},
|
||||
{
|
||||
description: "Point of sale solution for bridging, onramping & swapping",
|
||||
description: "Enable payments in any token on any chain",
|
||||
icon: PayIcon,
|
||||
id: "pay",
|
||||
link: "https://portal.thirdweb.com/connect/pay/overview",
|
||||
name: "Universal Bridge",
|
||||
name: "Payments",
|
||||
},
|
||||
{
|
||||
description: "Query, transform and analyze blockchain data",
|
||||
description: "Query and analyze blockchain data",
|
||||
icon: InsightIcon,
|
||||
id: "insight",
|
||||
link: "https://thirdweb.com/insight",
|
||||
|
||||
@@ -2,11 +2,7 @@ import "server-only";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import {
|
||||
getChainServices,
|
||||
getChains,
|
||||
getGasSponsoredChains,
|
||||
} from "@/api/chain";
|
||||
import { getChainServices, getChains } from "@/api/chain";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { ChainMetadataWithServices, ChainServices } from "@/types/chain";
|
||||
import type { ChainCTAProps } from "./[chain_id]/(chainPage)/components/server/cta-card";
|
||||
@@ -25,7 +21,6 @@ import cotiBanner from "./temp-assets/COTI_Banner.jpg";
|
||||
import cotiCTA from "./temp-assets/COTI_CTA.jpg";
|
||||
import creatorBanner from "./temp-assets/creatorBanner.png";
|
||||
import creatorCTA from "./temp-assets/creatorCTA.png";
|
||||
import superchainCTABG from "./temp-assets/cta-bg-superchain.png";
|
||||
import xaiCTABg from "./temp-assets/cta-bg-xai-connect.png";
|
||||
import thirdwebCTA from "./temp-assets/cta-thirdweb.png";
|
||||
import cyberChainBanner from "./temp-assets/cyberChainBanner.png";
|
||||
@@ -135,17 +130,6 @@ type ExtraChainMetadata = Partial<{
|
||||
cta: ChainCTAProps;
|
||||
}>;
|
||||
|
||||
// TEMPORARY
|
||||
|
||||
const OP_CTA = {
|
||||
backgroundImageUrl: superchainCTABG.src,
|
||||
buttonLink: "/team/~/~/settings/credits",
|
||||
buttonText: "Apply now",
|
||||
description:
|
||||
"Successful applicants receive gas grants for use across all supported Optimism Superchain networks. These grants can sponsor gas fees for any onchain activity using our Account Abstraction tools.",
|
||||
title: "Optimism Superchain App Accelerator",
|
||||
} satisfies ChainCTAProps;
|
||||
|
||||
const chainMetaRecord = {
|
||||
// Flare
|
||||
14: {
|
||||
@@ -933,22 +917,12 @@ const chainMetaRecord = {
|
||||
export async function getChainMetadata(
|
||||
chainId: number,
|
||||
): Promise<(ExtraChainMetadata & { gasSponsored?: true }) | null> {
|
||||
const gasSponsoredChains = await getGasSponsoredChains();
|
||||
|
||||
const isGasSponsored = gasSponsoredChains.includes(chainId);
|
||||
|
||||
// TODO: fetch this from the API
|
||||
if (chainId in chainMetaRecord) {
|
||||
return {
|
||||
...(isGasSponsored ? { cta: OP_CTA, gasSponsored: true } : {}),
|
||||
// this will OVERRIDE the op CTA if there is a custom one configured
|
||||
...chainMetaRecord[chainId as keyof typeof chainMetaRecord],
|
||||
};
|
||||
} else if (isGasSponsored) {
|
||||
return {
|
||||
cta: OP_CTA,
|
||||
gasSponsored: true,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Suspense } from "react";
|
||||
import {
|
||||
ContractCard,
|
||||
ContractCardSkeleton,
|
||||
} from "../../../../../../@/components/contracts/contract-card";
|
||||
} from "@/components/contracts/contract-card";
|
||||
|
||||
interface ContractRowProps {
|
||||
category: ExploreCategory;
|
||||
|
||||
+12
-14
@@ -182,19 +182,17 @@ function ContractTableRow(props: { row: Row<ContractDataRow> }) {
|
||||
const { row } = props;
|
||||
const { key, ...rowProps } = row.getRowProps();
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-card"
|
||||
linkBox
|
||||
{...rowProps}
|
||||
key={key}
|
||||
>
|
||||
{row.cells.map((cell) => (
|
||||
<TableCell {...cell.getCellProps()} key={cell.getCellProps().key}>
|
||||
{cell.render("Cell")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-card"
|
||||
linkBox
|
||||
{...rowProps}
|
||||
key={key}
|
||||
>
|
||||
{row.cells.map((cell) => (
|
||||
<TableCell {...cell.getCellProps()} key={cell.getCellProps().key}>
|
||||
{cell.render("Cell")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user