Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bbff67db0c | ||
|
|
ef2895fa2c | ||
|
|
cf44dae9ed | ||
|
|
c26be2d958 | ||
|
|
029741126f | ||
|
|
b5114dfe22 | ||
|
|
66270f7933 | ||
|
|
11b4d9e780 | ||
|
|
ad5aabcb7c | ||
|
|
3107ba1c37 | ||
|
|
897bdc75d5 | ||
|
|
87c645d633 | ||
|
|
15b4b4b2f9 | ||
|
|
18ad1afbde | ||
|
|
e70e9b9fcc | ||
|
|
dfe913e2a9 | ||
|
|
5bc50eb339 | ||
|
|
b61ce679e1 | ||
|
|
56495a1217 | ||
|
|
c9b4730ecb | ||
|
|
ea3c702c4f | ||
|
|
51798f09be | ||
|
|
387a739544 | ||
|
|
6fd630af1f | ||
|
|
ff6e2453d0 | ||
|
|
eeb672d092 | ||
|
|
9667c74981 | ||
|
|
997beeac3a | ||
|
|
9fec4ccec4 | ||
|
|
9c420c0c9d | ||
|
|
ffea73c719 | ||
|
|
f56cc00187 | ||
|
|
6be9459361 | ||
|
|
492c6d0194 | ||
|
|
787118d367 | ||
|
|
70e024f43e | ||
|
|
4c2988623f | ||
|
|
5492331263 | ||
|
|
1397adf32e | ||
|
|
2aa9ce5f78 | ||
|
|
d9eee50dc1 | ||
|
|
5f2bca8371 | ||
|
|
6b24fed21f | ||
|
|
cc3fbba895 | ||
|
|
381e423d59 | ||
|
|
c70d5ab013 | ||
|
|
3dbf9e16fd | ||
|
|
be49798e99 | ||
|
|
167f9213ff | ||
|
|
bb09e20bcb | ||
|
|
78b9c8e334 | ||
|
|
a5163c0c7f | ||
|
|
55baa997f0 | ||
|
|
c75e434498 | ||
|
|
2b5941ff7d | ||
|
|
85b21ec65c | ||
|
|
508b4c48e5 | ||
|
|
a5e086f173 |
@@ -1,5 +1,11 @@
|
||||
{
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
"onlyUpdatePeerDependentsWhenOutOfRange": true,
|
||||
"updateInternalDependents": "always"
|
||||
},
|
||||
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"changelog": [
|
||||
"@changesets/changelog-github",
|
||||
{
|
||||
@@ -7,21 +13,15 @@
|
||||
}
|
||||
],
|
||||
"commit": false,
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [
|
||||
"playground-web",
|
||||
"thirdweb-dashboard",
|
||||
"wallet-ui",
|
||||
"portal",
|
||||
"thirdweb-login"
|
||||
"nebula-app"
|
||||
],
|
||||
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
|
||||
"updateInternalDependents": "always",
|
||||
"onlyUpdatePeerDependentsWhenOutOfRange": true
|
||||
},
|
||||
"snapshot": {
|
||||
"prereleaseTemplate": "{tag}-{commit}-{datetime}"
|
||||
}
|
||||
},
|
||||
"updateInternalDependencies": "patch"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ description: Rules for writing features in apps/dashboard
|
||||
globs: dashboard
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Reusable Core UI Components
|
||||
|
||||
- Always import from the central UI library under `@/components/ui/*` – e.g. `import { Button } from "@/components/ui/button"`.
|
||||
@@ -101,3 +100,29 @@ Guidelines:
|
||||
- Keep `queryKey` stable and descriptive for cache hits.
|
||||
- Prefer API routes or server actions to keep tokens secret; the browser only sees relative paths.
|
||||
- Configure `staleTime` / `cacheTime` according to freshness requirements.
|
||||
|
||||
# Analytics Event Reporting
|
||||
|
||||
- **Add events intentionally** – only when they answer a concrete product/business question.
|
||||
- **Event name**: human-readable `<subject> <verb>` phrase (e.g. `"contract deployed"`).
|
||||
- **Reporting helper**: `report<Subject><Verb>` (PascalCase); all live in `src/@/analytics/report.ts`.
|
||||
- **Mandatory JSDoc**: explain *Why* the event exists and *Who* owns it (`@username`).
|
||||
- **Typed properties**: accept a single `properties` object and pass it unchanged to `posthog.capture`.
|
||||
- **Client-side only**: never import `posthog-js` in server components.
|
||||
- **Housekeeping**: ping **#core-services** before renaming or removing an event.
|
||||
|
||||
```ts
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - Tracks number of contracts deployed
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*/
|
||||
export function reportContractDeployed(properties: {
|
||||
address: string;
|
||||
chainId: number;
|
||||
}) {
|
||||
posthog.capture("contract deployed", properties);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@a9763ed3d2388f5746f9dc3e1a55df7f4609bc89 # v2.5.1
|
||||
with:
|
||||
version: latest
|
||||
version: 2.0.4
|
||||
|
||||
- run: pnpm lint
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
uses: github/codeql-action/autobuild@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
@@ -72,4 +72,4 @@ jobs:
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3.28.19
|
||||
uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports.biome": "explicit",
|
||||
"quickfix.biome": "explicit"
|
||||
"quickfix.biome": "explicit",
|
||||
"source.fixAll.biome": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.formatOnSave": true,
|
||||
"eslint.workingDirectories": [
|
||||
{ "pattern": "./packages/*/" },
|
||||
{ "pattern": "./apps/*/" }
|
||||
],
|
||||
"typescript.preferences.autoImportFileExcludePatterns": [
|
||||
"./packages/thirdweb/src/exports"
|
||||
],
|
||||
@@ -15,27 +36,5 @@
|
||||
"next/dist",
|
||||
"^lucide-react/dist/lucide-react.suffixed$"
|
||||
],
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[markdown]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[css]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"eslint.workingDirectories": [
|
||||
{ "pattern": "./packages/*/" },
|
||||
{ "pattern": "./apps/*/" }
|
||||
]
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
@@ -104,6 +104,41 @@ Welcome, AI copilots! This guide captures the coding standards, architectural de
|
||||
-- Configure staleTime / cacheTime based on freshness requirements (default ≥ 60 s).
|
||||
-- Keep tokens secret by calling internal API routes or server actions.
|
||||
|
||||
6.5 Analytics Event Reporting
|
||||
|
||||
- **When to create a new event**
|
||||
-- Only add events that answer a clear product or business question.
|
||||
-- Check `src/@/analytics/report.ts` first; avoid duplicates.
|
||||
|
||||
- **Naming conventions**
|
||||
-- **Event name**: human-readable phrase in the form `<subject> <verb>` (e.g. "contract deployed").
|
||||
-- **Reporting function**: `report<Subject><Verb>` (PascalCase).
|
||||
-- All reporting helpers currently live in the shared `report.ts` file.
|
||||
|
||||
- **Boilerplate template**
|
||||
-- Add a JSDoc header explaining **Why** the event exists and **Who** owns it (`@username`).
|
||||
-- Accept a single typed `properties` object and forward it unchanged to `posthog.capture`.
|
||||
-- Example:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - Tracks number of contracts deployed
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*/
|
||||
export function reportContractDeployed(properties: {
|
||||
address: string;
|
||||
chainId: number;
|
||||
}) {
|
||||
posthog.capture("contract deployed", properties);
|
||||
}
|
||||
```
|
||||
|
||||
- **Client-side only**: never import `posthog-js` in server components.
|
||||
- **Housekeeping**: Inform **#eng-core-services** before renaming or removing an existing event.
|
||||
|
||||
⸻
|
||||
|
||||
7. Performance & Bundle Size
|
||||
|
||||
@@ -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
|
||||
@@ -44,9 +44,6 @@ NEXT_PUBLIC_STRIPE_KEY=
|
||||
|
||||
NEXT_PUBLIC_STRIPE_PAYMENT_METHOD_CFG_ID=
|
||||
|
||||
# Needed for contract analytics / blockchain data information
|
||||
CHAINSAW_API_KEY=
|
||||
|
||||
#
|
||||
# Private (server)
|
||||
#
|
||||
@@ -65,17 +62,11 @@ MORALIS_API_KEY=
|
||||
# - not required to build (unless testing wallet NFTs)>
|
||||
SSR_ALCHEMY_KEY=
|
||||
|
||||
# beehiiv.com API key (used for newsletter signups)
|
||||
# - not required to build (unless testing newsletter signups)>
|
||||
BEEHIIV_API_KEY=
|
||||
|
||||
# Hubspot Access Token (used for contact us form)
|
||||
# - not required to build (unless testing contact us form)>
|
||||
HUBSPOT_ACCESS_TOKEN=
|
||||
|
||||
# Github API Token (used for /open-source)
|
||||
GITHUB_API_TOKEN="ghp_..."
|
||||
|
||||
# Upload server url
|
||||
NEXT_PUBLIC_DASHBOARD_UPLOAD_SERVER="https://storage.thirdweb-preview.com"
|
||||
|
||||
@@ -100,8 +91,6 @@ REDIS_URL=""
|
||||
|
||||
ANALYTICS_SERVICE_URL=""
|
||||
|
||||
# Required for Nebula Chat
|
||||
NEXT_PUBLIC_NEBULA_URL=""
|
||||
|
||||
# required for billing parts of the dashboard (team -> settings -> billing / invoices)
|
||||
STRIPE_SECRET_KEY=""
|
||||
|
||||
@@ -1,52 +1,98 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:@next/next/recommended",
|
||||
"plugin:storybook/recommended",
|
||||
],
|
||||
overrides: [
|
||||
// disable restricted imports in chakra
|
||||
{
|
||||
files: "src/chakra/**/*",
|
||||
rules: {
|
||||
"no-restricted-imports": ["off"],
|
||||
},
|
||||
},
|
||||
// allow direct PostHog imports inside analytics helpers
|
||||
{
|
||||
files: "src/@/analytics/**/*",
|
||||
rules: {
|
||||
"no-restricted-imports": ["off"],
|
||||
},
|
||||
},
|
||||
// enable rule specifically for TypeScript files
|
||||
{
|
||||
files: ["*.ts", "*.tsx"],
|
||||
rules: {
|
||||
"@typescript-eslint/explicit-module-boundary-types": ["off"],
|
||||
},
|
||||
},
|
||||
|
||||
// in test files, allow null assertions and anys and eslint is sometimes weird about the react-scope thing
|
||||
{
|
||||
files: ["*test.ts?(x)"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
|
||||
"react/display-name": "off",
|
||||
},
|
||||
},
|
||||
// allow requires in non-transpiled JS files and logical key ordering in config files
|
||||
{
|
||||
files: [
|
||||
"babel-node.js",
|
||||
"*babel.config.js",
|
||||
"env.config.js",
|
||||
"next.config.js",
|
||||
"webpack.config.js",
|
||||
"packages/mobile-web/package-builder/**",
|
||||
],
|
||||
rules: {},
|
||||
},
|
||||
|
||||
// setupTests can have separated imports for logical grouping
|
||||
{
|
||||
files: ["setupTests.ts"],
|
||||
rules: {
|
||||
"import/newline-after-import": "off",
|
||||
},
|
||||
},
|
||||
// turn OFF unused vars via eslint
|
||||
{
|
||||
files: ["*.ts", "*.tsx"],
|
||||
rules: {
|
||||
"@next/next/no-img-element": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
},
|
||||
},
|
||||
// THIS NEEDS TO GO LAST!
|
||||
{
|
||||
extends: ["biome"],
|
||||
files: ["*.ts", "*.js", "*.tsx", "*.jsx"],
|
||||
},
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
impliedStrict: true,
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 2019,
|
||||
warnOnUnsupportedTypeScriptVersion: true,
|
||||
},
|
||||
plugins: ["@typescript-eslint", "react-compiler"],
|
||||
rules: {
|
||||
"react-compiler/react-compiler": "error",
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector: "CallExpression[callee.name='useEffect']",
|
||||
message:
|
||||
'Are you *sure* you need to use "useEffect" here? If you loading any async function prefer using "useQuery".',
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.name='createContext']",
|
||||
message:
|
||||
'Are you *sure* you need to use a "Context"? In almost all cases you should prefer passing props directly.',
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.name='defineChain']",
|
||||
message:
|
||||
"Use useV5DashboardChain instead if you are using it inside a component",
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.name='defineDashboardChain']",
|
||||
message:
|
||||
"Use useV5DashboardChain instead if you are using it inside a component",
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.name='mapV4ChainToV5Chain']",
|
||||
message:
|
||||
"Use useV5DashboardChain instead if you are using it inside a component",
|
||||
},
|
||||
{
|
||||
selector: "CallExpression[callee.name='resolveScheme']",
|
||||
message:
|
||||
"resolveScheme can throw error if resolution fails. Either catch the error and ignore the lint warning or Use `resolveSchemeWithErrorHandler` / `replaceIpfsUrl` utility in dashboard instead",
|
||||
},
|
||||
],
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "@chakra-ui/react",
|
||||
// these are provided by tw-components, so we don't want to import them from chakra directly
|
||||
// these are provided by chakra folder, so we don't want to import them from chakra directly
|
||||
importNames: [
|
||||
"Card",
|
||||
"Button",
|
||||
@@ -81,48 +127,72 @@ module.exports = {
|
||||
"StackProps",
|
||||
],
|
||||
message:
|
||||
'Use the equivalent component from "tw-components" instead.',
|
||||
'import component from "chakra" folder instead if you have to use chakra component, But use shadcn component otherwise',
|
||||
name: "@chakra-ui/react",
|
||||
},
|
||||
{
|
||||
name: "@chakra-ui/layout",
|
||||
message:
|
||||
"Import from `@chakra-ui/react` instead of `@chakra-ui/layout`.",
|
||||
name: "@chakra-ui/layout",
|
||||
},
|
||||
{
|
||||
name: "@chakra-ui/button",
|
||||
message:
|
||||
"Import from `@chakra-ui/react` instead of `@chakra-ui/button`.",
|
||||
name: "@chakra-ui/button",
|
||||
},
|
||||
{
|
||||
name: "@chakra-ui/menu",
|
||||
message:
|
||||
"Import from `@chakra-ui/react` instead of `@chakra-ui/menu`.",
|
||||
name: "@chakra-ui/menu",
|
||||
},
|
||||
{
|
||||
name: "next/navigation",
|
||||
importNames: ["useRouter"],
|
||||
message:
|
||||
'Use `import { useDashboardRouter } from "@/lib/DashboardRouter";` instead',
|
||||
name: "next/navigation",
|
||||
},
|
||||
{
|
||||
name: "lucide-react",
|
||||
importNames: ["Link", "Table", "Sidebar"],
|
||||
message:
|
||||
'This is likely a mistake. If you really want to import this - postfix the imported name with Icon. Example - "LinkIcon"',
|
||||
'Import "posthog-js" directly only within the analytics helpers ("src/@/analytics/*"). Use the exported helpers from "@/analytics/track" elsewhere.',
|
||||
name: "posthog-js",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
parser: "@typescript-eslint/parser",
|
||||
plugins: ["@typescript-eslint", "react-compiler"],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2019,
|
||||
ecmaFeatures: {
|
||||
impliedStrict: true,
|
||||
jsx: true,
|
||||
},
|
||||
warnOnUnsupportedTypeScriptVersion: true,
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
message:
|
||||
'Are you *sure* you need to use "useEffect" here? If you loading any async function prefer using "useQuery".',
|
||||
selector: "CallExpression[callee.name='useEffect']",
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Are you *sure* you need to use a "Context"? In almost all cases you should prefer passing props directly.',
|
||||
selector: "CallExpression[callee.name='createContext']",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Use useV5DashboardChain instead if you are using it inside a component",
|
||||
selector: "CallExpression[callee.name='defineChain']",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Use useV5DashboardChain instead if you are using it inside a component",
|
||||
selector: "CallExpression[callee.name='defineDashboardChain']",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Use useV5DashboardChain instead if you are using it inside a component",
|
||||
selector: "CallExpression[callee.name='mapV4ChainToV5Chain']",
|
||||
},
|
||||
{
|
||||
message:
|
||||
"resolveScheme can throw error if resolution fails. Either catch the error and ignore the lint warning or Use `resolveSchemeWithErrorHandler` / `replaceIpfsUrl` utility in dashboard instead",
|
||||
selector: "CallExpression[callee.name='resolveScheme']",
|
||||
},
|
||||
],
|
||||
"react-compiler/react-compiler": "error",
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
@@ -131,60 +201,4 @@ module.exports = {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
overrides: [
|
||||
// disable restricted imports in tw-components
|
||||
{
|
||||
files: "src/tw-components/**/*",
|
||||
rules: {
|
||||
"no-restricted-imports": ["off"],
|
||||
},
|
||||
},
|
||||
// enable rule specifically for TypeScript files
|
||||
{
|
||||
files: ["*.ts", "*.tsx"],
|
||||
rules: {
|
||||
"@typescript-eslint/explicit-module-boundary-types": ["off"],
|
||||
},
|
||||
},
|
||||
|
||||
// in test files, allow null assertions and anys and eslint is sometimes weird about the react-scope thing
|
||||
{
|
||||
files: ["*test.ts?(x)"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
|
||||
"react/display-name": "off",
|
||||
},
|
||||
},
|
||||
// allow requires in non-transpiled JS files and logical key ordering in config files
|
||||
{
|
||||
files: [
|
||||
"babel-node.js",
|
||||
"*babel.config.js",
|
||||
"env.config.js",
|
||||
"next.config.js",
|
||||
"webpack.config.js",
|
||||
"packages/mobile-web/package-builder/**",
|
||||
],
|
||||
rules: {},
|
||||
},
|
||||
|
||||
// setupTests can have separated imports for logical grouping
|
||||
{
|
||||
files: ["setupTests.ts"],
|
||||
rules: {
|
||||
"import/newline-after-import": "off",
|
||||
},
|
||||
},
|
||||
// THIS NEEDS TO GO LAST!
|
||||
{
|
||||
files: ["*.ts", "*.js", "*.tsx", "*.jsx"],
|
||||
extends: ["biome"],
|
||||
},
|
||||
],
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.2/schema.json",
|
||||
"extends": ["../../biome.json"],
|
||||
"overrides": [
|
||||
{
|
||||
"include": ["src/css/swagger-ui.css"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"suspicious": {
|
||||
"noImportantInKeyframe": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.4/schema.json",
|
||||
"extends": "//"
|
||||
}
|
||||
|
||||
@@ -2,29 +2,29 @@ import { defineConfig } from "checkly";
|
||||
import { Frequency } from "checkly/constructs";
|
||||
|
||||
export default defineConfig({
|
||||
projectName: "thirdweb.com",
|
||||
logicalId: "thirdweb-www",
|
||||
repoUrl: "https://github.com/thirdweb-dev/dashboard",
|
||||
checks: {
|
||||
activated: true,
|
||||
muted: false,
|
||||
runtimeId: "2023.09",
|
||||
frequency: Frequency.EVERY_24H,
|
||||
locations: ["us-east-1", "eu-west-1"],
|
||||
tags: ["website"],
|
||||
browserChecks: {
|
||||
frequency: Frequency.EVERY_24H,
|
||||
testMatch: "./tests/**/*.spec.ts",
|
||||
},
|
||||
checkMatch: "./**/*.check.ts",
|
||||
frequency: Frequency.EVERY_24H,
|
||||
ignoreDirectoriesMatch: [],
|
||||
locations: ["us-east-1", "eu-west-1"],
|
||||
muted: false,
|
||||
playwrightConfig: {
|
||||
use: {
|
||||
baseURL: process.env.ENVIRONMENT_URL || "https://thirdweb.com",
|
||||
},
|
||||
},
|
||||
browserChecks: {
|
||||
frequency: Frequency.EVERY_24H,
|
||||
testMatch: "./tests/**/*.spec.ts",
|
||||
},
|
||||
runtimeId: "2023.09",
|
||||
tags: ["website"],
|
||||
},
|
||||
cli: {
|
||||
runLocation: "eu-west-1",
|
||||
},
|
||||
logicalId: "thirdweb-www",
|
||||
projectName: "thirdweb.com",
|
||||
repoUrl: "https://github.com/thirdweb-dev/dashboard",
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/global.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rsc": true,
|
||||
"style": "default",
|
||||
"tailwind": {
|
||||
"baseColor": "neutral",
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/global.css",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"tsx": true
|
||||
}
|
||||
|
||||
@@ -6,13 +6,14 @@ module.exports = [
|
||||
"/bounties",
|
||||
"/contact-us",
|
||||
// -- product landing pages --
|
||||
// -- connect
|
||||
"/connect",
|
||||
"/connect/sign-in",
|
||||
"/connect/account-abstraction",
|
||||
"/connect/universal-bridge",
|
||||
"/connect/auth",
|
||||
"/connect/in-app-wallets",
|
||||
// -- build category
|
||||
"/wallets",
|
||||
"/account-abstraction",
|
||||
"/universal-bridge",
|
||||
"/auth",
|
||||
"/in-app-wallets",
|
||||
"/transactions",
|
||||
// -- end build category
|
||||
// -- storage
|
||||
"/storage",
|
||||
// -- nebula
|
||||
@@ -24,8 +25,7 @@ module.exports = [
|
||||
"/contracts/modular-contracts",
|
||||
"/contracts/explore",
|
||||
"/contracts/deployment-tool",
|
||||
// -- engine
|
||||
"/engine",
|
||||
|
||||
// -- solutions pages --
|
||||
"/solutions/:solution_slug",
|
||||
// -- campaigns --
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@5/schema.json",
|
||||
"next": true,
|
||||
"ignore": [
|
||||
"src/@/components/ui/**",
|
||||
"src/components/notices/AnnouncementBanner.tsx",
|
||||
"src/components/cmd-k-search/index.tsx",
|
||||
"src/lib/search.ts"
|
||||
"src/@/components/misc/AnnouncementBanner.tsx",
|
||||
"src/@/components/cmd-k-search/index.tsx",
|
||||
"src/@/lib/search.ts"
|
||||
],
|
||||
"project": ["src/**"],
|
||||
"ignoreBinaries": ["only-allow", "biome"],
|
||||
"ignoreBinaries": ["only-allow"],
|
||||
"ignoreDependencies": [
|
||||
"@thirdweb-dev/service-utils",
|
||||
"@thirdweb-dev/vault-sdk",
|
||||
"@types/color",
|
||||
"fast-xml-parser"
|
||||
]
|
||||
],
|
||||
"next": true,
|
||||
"project": ["src/**"]
|
||||
}
|
||||
|
||||
@@ -44,12 +44,37 @@ async function getSingleChain(chainIdOrSlug) {
|
||||
|
||||
/** @type {import('next-sitemap').IConfig} */
|
||||
module.exports = {
|
||||
siteUrl: process.env.SITE_URL || "https://thirdweb.com",
|
||||
additionalPaths: async (config) => {
|
||||
const [framerUrls, allChains] = await Promise.all([
|
||||
getFramerXML(),
|
||||
fetchChainsFromApi(),
|
||||
]);
|
||||
|
||||
return [
|
||||
...framerUrls.map((url) => {
|
||||
return {
|
||||
changefreq: config.changefreq,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
loc: url.loc,
|
||||
priority: config.priority,
|
||||
};
|
||||
}),
|
||||
...allChains.map((chain) => {
|
||||
return {
|
||||
changefreq: config.changefreq,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
loc: `/${chain.slug}`,
|
||||
priority: config.priority,
|
||||
};
|
||||
}),
|
||||
...(await createSearchRecordSitemaps(config)),
|
||||
];
|
||||
},
|
||||
exclude: ["/chain/validate"],
|
||||
generateRobotsTxt: true,
|
||||
robotsTxtOptions: {
|
||||
policies: [
|
||||
{
|
||||
userAgent: "*",
|
||||
// allow all if production
|
||||
allow: process.env.VERCEL_ENV === "production" ? ["/"] : [],
|
||||
// disallow all if not production
|
||||
@@ -58,10 +83,11 @@ module.exports = {
|
||||
? ["/"]
|
||||
: // disallow `/team` and `/team/*` if production
|
||||
["/team", "/team/*"],
|
||||
userAgent: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
exclude: ["/chain/validate"],
|
||||
siteUrl: process.env.SITE_URL || "https://thirdweb.com",
|
||||
transform: async (config, _path) => {
|
||||
let path = _path;
|
||||
|
||||
@@ -75,40 +101,14 @@ module.exports = {
|
||||
path = path.replace("deployer.thirdweb.eth", "thirdweb.eth");
|
||||
}
|
||||
return {
|
||||
alternateRefs: config.alternateRefs ?? [],
|
||||
changefreq: config.changefreq,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
// => this will be exported as http(s)://<config.siteUrl>/<path>
|
||||
loc: path,
|
||||
changefreq: config.changefreq,
|
||||
priority: config.priority,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
alternateRefs: config.alternateRefs ?? [],
|
||||
};
|
||||
},
|
||||
additionalPaths: async (config) => {
|
||||
const [framerUrls, allChains] = await Promise.all([
|
||||
getFramerXML(),
|
||||
fetchChainsFromApi(),
|
||||
]);
|
||||
|
||||
return [
|
||||
...framerUrls.map((url) => {
|
||||
return {
|
||||
loc: url.loc,
|
||||
changefreq: config.changefreq,
|
||||
priority: config.priority,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
};
|
||||
}),
|
||||
...allChains.map((chain) => {
|
||||
return {
|
||||
loc: `/${chain.slug}`,
|
||||
changefreq: config.changefreq,
|
||||
priority: config.priority,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
};
|
||||
}),
|
||||
...(await createSearchRecordSitemaps(config)),
|
||||
];
|
||||
},
|
||||
};
|
||||
/**
|
||||
* @param {{ changefreq?: any; priority?: any; autoLastmod?: any; }} config
|
||||
@@ -131,10 +131,10 @@ async function createSearchRecordSitemaps(config) {
|
||||
parsedLines.map((parsedLine) => {
|
||||
return getSingleChain(parsedLine.chain_id)
|
||||
.then((parsedLineChain) => ({
|
||||
loc: `/${parsedLineChain.slug}/${parsedLine.contract_address}`,
|
||||
changefreq: config.changefreq,
|
||||
priority: config.priority,
|
||||
lastmod: config.autoLastmod ? new Date().toISOString() : undefined,
|
||||
loc: `/${parsedLineChain.slug}/${parsedLine.contract_address}`,
|
||||
priority: config.priority,
|
||||
}))
|
||||
.catch(() => null);
|
||||
}),
|
||||
|
||||
@@ -46,91 +46,90 @@ function determineIpfsGateways() {
|
||||
const remotePatterns: RemotePattern[] = [];
|
||||
if (process.env.API_ROUTES_CLIENT_ID) {
|
||||
remotePatterns.push({
|
||||
protocol: "https",
|
||||
hostname: `${process.env.API_ROUTES_CLIENT_ID}.ipfscdn.io`,
|
||||
protocol: "https",
|
||||
});
|
||||
remotePatterns.push({
|
||||
protocol: "https",
|
||||
hostname: `${process.env.API_ROUTES_CLIENT_ID}.thirdwebstorage-staging.com`,
|
||||
protocol: "https",
|
||||
});
|
||||
remotePatterns.push({
|
||||
protocol: "https",
|
||||
hostname: `${process.env.API_ROUTES_CLIENT_ID}.thirdwebstorage-dev.com`,
|
||||
protocol: "https",
|
||||
});
|
||||
} else {
|
||||
// this should only happen in development
|
||||
remotePatterns.push({
|
||||
protocol: "https",
|
||||
hostname: "ipfs.io",
|
||||
protocol: "https",
|
||||
});
|
||||
}
|
||||
// also add the dashboard clientId ipfs gateway if it is set
|
||||
if (process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID) {
|
||||
remotePatterns.push({
|
||||
protocol: "https",
|
||||
hostname: `${process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID}.ipfscdn.io`,
|
||||
protocol: "https",
|
||||
});
|
||||
remotePatterns.push({
|
||||
protocol: "https",
|
||||
hostname: `${process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID}.thirdwebstorage-staging.com`,
|
||||
protocol: "https",
|
||||
});
|
||||
remotePatterns.push({
|
||||
protocol: "https",
|
||||
hostname: `${process.env.NEXT_PUBLIC_DASHBOARD_CLIENT_ID}.thirdwebstorage-dev.com`,
|
||||
protocol: "https",
|
||||
});
|
||||
}
|
||||
return remotePatterns;
|
||||
}
|
||||
|
||||
const SENTRY_OPTIONS: SentryBuildOptions = {
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
org: "thirdweb-dev",
|
||||
project: "dashboard",
|
||||
// An auth token is required for uploading source maps.
|
||||
authToken: process.env.SENTRY_AUTH_TOKEN,
|
||||
// Suppresses source map uploading logs during build
|
||||
silent: true,
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
||||
tunnelRoute: "/err",
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors.
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: false,
|
||||
|
||||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
// For all available options, see:
|
||||
// https://github.com/getsentry/sentry-webpack-plugin#options
|
||||
|
||||
org: "thirdweb-dev",
|
||||
project: "dashboard",
|
||||
// Suppresses source map uploading logs during build
|
||||
silent: true,
|
||||
|
||||
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
||||
tunnelRoute: "/err",
|
||||
// For all available options, see:
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
|
||||
|
||||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
};
|
||||
|
||||
// add additional languages to the framer rewrite paths here (english is already included by default)
|
||||
const FRAMER_ADDITIONAL_LANGUAGES = ["es"];
|
||||
|
||||
const baseNextConfig: NextConfig = {
|
||||
compiler: {
|
||||
emotion: true,
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
productionBrowserSourceMaps: false,
|
||||
experimental: {
|
||||
webpackBuildWorker: true,
|
||||
webpackMemoryOptimizations: true,
|
||||
serverSourceMaps: false,
|
||||
taint: true,
|
||||
webpackBuildWorker: true,
|
||||
webpackMemoryOptimizations: true,
|
||||
},
|
||||
serverExternalPackages: ["pino-pretty"],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
// Apply these headers to all routes in your application.
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
...securityHeaders,
|
||||
{
|
||||
@@ -138,56 +137,69 @@ const baseNextConfig: NextConfig = {
|
||||
value: "sec-ch-viewport-width",
|
||||
},
|
||||
],
|
||||
// Apply these headers to all routes in your application.
|
||||
source: "/(.*)",
|
||||
},
|
||||
];
|
||||
},
|
||||
images: {
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
dangerouslyAllowSVG: true,
|
||||
remotePatterns: [
|
||||
{
|
||||
hostname: "**.thirdweb.com",
|
||||
protocol: "https",
|
||||
},
|
||||
...determineIpfsGateways(),
|
||||
],
|
||||
},
|
||||
productionBrowserSourceMaps: false,
|
||||
reactStrictMode: true,
|
||||
async redirects() {
|
||||
return getRedirects();
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/thirdweb.eth",
|
||||
destination: "/deployer.thirdweb.eth",
|
||||
destination: "https://us-assets.i.posthog.com/static/:path*",
|
||||
source: "/_ph/static/:path*",
|
||||
},
|
||||
{
|
||||
destination: "https://us.i.posthog.com/:path*",
|
||||
source: "/_ph/:path*",
|
||||
},
|
||||
{
|
||||
destination: "https://us.i.posthog.com/decide",
|
||||
source: "/_ph/decide",
|
||||
},
|
||||
{
|
||||
destination: "/deployer.thirdweb.eth",
|
||||
source: "/thirdweb.eth",
|
||||
},
|
||||
{
|
||||
source: "/thirdweb.eth/:path*",
|
||||
destination: "/deployer.thirdweb.eth/:path*",
|
||||
source: "/thirdweb.eth/:path*",
|
||||
},
|
||||
// re-write /home to / (this is so that logged in users will be able to go to /home and NOT be redirected to the logged in app)
|
||||
{
|
||||
source: "/home",
|
||||
destination: "https://landing.thirdweb.com",
|
||||
source: "/home",
|
||||
},
|
||||
// flatmap the framer paths for the default language and the additional languages
|
||||
...FRAMER_PATHS.flatMap((path) => [
|
||||
{
|
||||
source: path,
|
||||
destination: `https://landing.thirdweb.com${path}`,
|
||||
source: path,
|
||||
},
|
||||
// this is for additional languages
|
||||
...FRAMER_ADDITIONAL_LANGUAGES.map((lang) => ({
|
||||
source: `/${lang}${path}`,
|
||||
destination: `https://landing.thirdweb.com/${lang}${path}`,
|
||||
source: `/${lang}${path}`,
|
||||
})),
|
||||
]),
|
||||
];
|
||||
},
|
||||
images: {
|
||||
dangerouslyAllowSVG: true,
|
||||
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**.thirdweb.com",
|
||||
},
|
||||
...determineIpfsGateways(),
|
||||
],
|
||||
},
|
||||
compiler: {
|
||||
emotion: true,
|
||||
},
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ["pino-pretty"],
|
||||
};
|
||||
|
||||
function getConfig(): NextConfig {
|
||||
|
||||
@@ -1,26 +1,4 @@
|
||||
{
|
||||
"name": "thirdweb-dashboard",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"start": "next start",
|
||||
"format": "biome format ./src --write",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"gen:theme-typings": "chakra-cli tokens src/theme/index.ts",
|
||||
"postinstall": "pnpm run gen:theme-typings",
|
||||
"postbuild": "next-sitemap",
|
||||
"build:analyze": "ANALYZE=true pnpm run build",
|
||||
"knip": "knip",
|
||||
"playwright": "playwright test",
|
||||
"update-checkly": "npx checkly deploy",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@chakra-ui/styled-system": "^2.9.2",
|
||||
@@ -46,7 +24,7 @@
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tooltip": "1.2.7",
|
||||
"@scalar/api-reference-react": "^0.6.19",
|
||||
"@sentry/nextjs": "9.28.1",
|
||||
"@sentry/nextjs": "9.29.0",
|
||||
"@shazow/whatsabi": "0.22.2",
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
@@ -62,8 +40,6 @@
|
||||
"compare-versions": "^6.1.0",
|
||||
"date-fns": "4.1.0",
|
||||
"fast-xml-parser": "^5.2.5",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "12.17.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
@@ -78,7 +54,7 @@
|
||||
"p-limit": "^6.2.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"pluralize": "^8.0.0",
|
||||
"posthog-js": "1.67.1",
|
||||
"posthog-js": "1.252.0",
|
||||
"prettier": "3.5.3",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "19.1.0",
|
||||
@@ -105,9 +81,10 @@
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-debounce": "^10.0.5",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.62"
|
||||
"zod": "3.25.67"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.4",
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "4.0.0",
|
||||
"@next/bundle-analyzer": "15.3.3",
|
||||
@@ -141,5 +118,27 @@
|
||||
"storybook": "9.0.8",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
},
|
||||
"name": "thirdweb-dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"build-storybook": "storybook build",
|
||||
"build:analyze": "ANALYZE=true pnpm run build",
|
||||
"dev": "next dev --turbopack",
|
||||
"fix": "biome check ./src --fix && eslint ./src --fix",
|
||||
"format": "biome format ./src --write",
|
||||
"gen:theme-typings": "chakra-cli tokens src/chakra/theme/index.ts",
|
||||
"knip": "knip",
|
||||
"lint": "biome check ./src && knip && eslint ./src",
|
||||
"playwright": "playwright test",
|
||||
"postbuild": "next-sitemap",
|
||||
"postinstall": "pnpm run gen:theme-typings",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"start": "next start",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"update-checkly": "npx checkly deploy"
|
||||
},
|
||||
"version": "3.0.0"
|
||||
}
|
||||
|
||||
@@ -10,25 +10,10 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.ENVIRONMENT_URL || "http://127.0.0.1:3000",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
@@ -67,13 +52,28 @@ export default defineConfig({
|
||||
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||
// },
|
||||
],
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
testDir: "./tests",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.ENVIRONMENT_URL || "http://127.0.0.1:3000",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: process.env.CI
|
||||
? undefined
|
||||
: {
|
||||
command: "pnpm run start",
|
||||
url: "http://127.0.0.1:3000",
|
||||
reuseExistingServer: !process.env.CI,
|
||||
url: "http://127.0.0.1:3000",
|
||||
},
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 626 KiB |
|
Before Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.33106 4.3C8.33109 4.3 7.52045 5.11064 7.52045 6.11061C7.52045 7.11058 8.33109 7.92121 9.33106 7.92121C10.331 7.92121 11.1417 7.11058 11.1417 6.11061C11.1417 5.11064 10.331 4.3 9.33106 4.3ZM5.22045 6.11061C5.22045 3.84038 7.06084 2 9.33106 2C11.6013 2 13.4417 3.84038 13.4417 6.11061C13.4417 8.38083 11.6013 10.2212 9.33106 10.2212C7.06084 10.2212 5.22045 8.38083 5.22045 6.11061ZM4.20397 12.0858C4.97486 11.3149 6.0204 10.8818 7.11061 10.8818H11.5515C12.6417 10.8818 13.6873 11.3149 14.4581 12.0858C14.9073 12.5349 14.9073 13.263 14.4581 13.7121C14.009 14.1612 13.2809 14.1612 12.8318 13.7121C12.4922 13.3726 12.0317 13.1818 11.5515 13.1818H7.11061C6.6304 13.1818 6.16987 13.3726 5.83031 13.7121C5.49076 14.0517 5.3 14.5122 5.3 14.9924V16.4727C5.3 17.1079 4.78513 17.6227 4.15 17.6227C3.51487 17.6227 3 17.1079 3 16.4727V14.9924C3 13.9022 3.43308 12.8567 4.20397 12.0858ZM17.5457 14.9598C17.9948 14.5107 17.9948 13.7825 17.5457 13.3334C17.0966 12.8843 16.3685 12.8843 15.9194 13.3334L13.7719 15.4808L13.1048 14.8137C12.6557 14.3646 11.9276 14.3646 11.4785 14.8137C11.0294 15.2628 11.0294 15.991 11.4785 16.4401L12.9588 17.9204C13.4079 18.3695 14.136 18.3695 14.5851 17.9204L17.5457 14.9598Z" fill="#646D7A"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,4 +0,0 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15.4444 3H4.55556C3.69645 3 3 3.69645 3 4.55556V15.4444C3 16.3036 3.69645 17 4.55556 17H15.4444C16.3036 17 17 16.3036 17 15.4444V4.55556C17 3.69645 16.3036 3 15.4444 3Z" stroke="#646D7A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M12.3333 7.66666L7.66663 12.3333" stroke="#646D7A" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 492 B |
|
Before Width: | Height: | Size: 6.0 KiB |
@@ -1,64 +1,114 @@
|
||||
const legacyDashboardToTeamRedirects = [
|
||||
{
|
||||
source: "/dashboard",
|
||||
destination: "/team",
|
||||
permanent: false,
|
||||
source: "/dashboard",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/contracts/:path*",
|
||||
destination: "/team/~/~/contracts",
|
||||
permanent: false,
|
||||
source: "/dashboard/contracts/:path*",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/connect/ecosystem/:path*",
|
||||
destination: "/team/~/~/ecosystem/:path*",
|
||||
permanent: false,
|
||||
source: "/dashboard/connect/ecosystem/:path*",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/engine/:path*",
|
||||
destination: "/team/~/~/engine/:path*",
|
||||
permanent: false,
|
||||
source: "/dashboard/engine/:path*",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/api-keys",
|
||||
destination: "/team",
|
||||
permanent: false,
|
||||
source: "/dashboard/settings/api-keys",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/devices",
|
||||
destination: "/account/devices",
|
||||
permanent: false,
|
||||
source: "/dashboard/settings/devices",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/billing",
|
||||
destination: "/team/~/~/settings/billing",
|
||||
permanent: false,
|
||||
source: "/dashboard/settings/billing",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/gas-credits",
|
||||
destination: "/team/~/~/settings/credits",
|
||||
permanent: false,
|
||||
source: "/dashboard/settings/gas-credits",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/usage",
|
||||
destination: "/team/~/~/usage",
|
||||
permanent: false,
|
||||
source: "/dashboard/settings/usage",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/storage",
|
||||
destination: "/team/~/~/usage/storage",
|
||||
permanent: false,
|
||||
source: "/dashboard/settings/storage",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/settings/notifications",
|
||||
destination: "/team/~/~/settings/notifications",
|
||||
permanent: false,
|
||||
source: "/dashboard/settings/notifications",
|
||||
},
|
||||
// rest of the /dashboard/* routes
|
||||
{
|
||||
source: "/dashboard/:path*",
|
||||
destination: "/team",
|
||||
permanent: false,
|
||||
source: "/dashboard/:path*",
|
||||
},
|
||||
];
|
||||
|
||||
const projectRoute = "/team/:team_slug/:project_slug";
|
||||
|
||||
const projectPageRedirects = [
|
||||
{
|
||||
destination: `${projectRoute}/universal-bridge/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/connect/pay/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/universal-bridge/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/connect/universal-bridge/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/account-abstraction/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/connect/account-abstraction/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/wallets/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/connect/in-app-wallets/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/vault/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/engine/cloud/vault/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/transactions/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/engine/cloud/:path*`,
|
||||
},
|
||||
{
|
||||
destination: `${projectRoute}/tokens/:path*`,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/assets/:path*`,
|
||||
},
|
||||
{
|
||||
destination: projectRoute,
|
||||
permanent: false,
|
||||
source: `${projectRoute}/nebula/:path*`,
|
||||
},
|
||||
{
|
||||
source: `${projectRoute}/connect/analytics`,
|
||||
destination: `${projectRoute}`,
|
||||
permanent: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -66,316 +116,316 @@ const legacyDashboardToTeamRedirects = [
|
||||
async function redirects() {
|
||||
return [
|
||||
{
|
||||
source: "/portal/:match*",
|
||||
destination: "https://portal.thirdweb.com/:match*",
|
||||
permanent: true,
|
||||
source: "/portal/:match*",
|
||||
},
|
||||
{
|
||||
source: "/solutions/appchain-api",
|
||||
destination: "/solutions/chains",
|
||||
permanent: true,
|
||||
source: "/solutions/appchain-api",
|
||||
},
|
||||
{
|
||||
source: "/contracts/release",
|
||||
destination: "/contracts/publish",
|
||||
permanent: false,
|
||||
source: "/contracts/release",
|
||||
},
|
||||
{
|
||||
source: "/contracts/release/:path*",
|
||||
destination: "/contracts/publish/:path*",
|
||||
permanent: false,
|
||||
source: "/contracts/release/:path*",
|
||||
},
|
||||
{
|
||||
source: "/release",
|
||||
destination: "/publish",
|
||||
permanent: false,
|
||||
source: "/release",
|
||||
},
|
||||
{
|
||||
source: "/release/:path*",
|
||||
destination: "/publish/:path*",
|
||||
permanent: false,
|
||||
source: "/release/:path*",
|
||||
},
|
||||
{
|
||||
source: "/authentication",
|
||||
destination: "/auth",
|
||||
permanent: false,
|
||||
source: "/authentication",
|
||||
},
|
||||
{
|
||||
source: "/extensions",
|
||||
destination: "/build",
|
||||
permanent: false,
|
||||
source: "/extensions",
|
||||
},
|
||||
{
|
||||
source: "/contractkit",
|
||||
destination: "/build",
|
||||
permanent: true,
|
||||
source: "/contractkit",
|
||||
},
|
||||
// old (deprecated) routes
|
||||
{
|
||||
source:
|
||||
"/:network/(edition|nft-collection|token|nft-drop|signature-drop|edition-drop|token-drop|vote)/:address",
|
||||
destination: "/:network/:address",
|
||||
permanent: false,
|
||||
source:
|
||||
"/:network/(edition|nft-collection|token|nft-drop|signature-drop|edition-drop|token-drop|vote)/:address",
|
||||
},
|
||||
// prebuilt contract deploys
|
||||
{
|
||||
source: "/contracts/new/:slug*",
|
||||
destination: "/explore",
|
||||
permanent: false,
|
||||
source: "/contracts/new/:slug*",
|
||||
},
|
||||
// deployer to non-deployer url
|
||||
// handled directly in SSR as well
|
||||
{
|
||||
source: "/deployer.thirdweb.eth",
|
||||
destination: "/thirdweb.eth",
|
||||
permanent: true,
|
||||
source: "/deployer.thirdweb.eth",
|
||||
},
|
||||
{
|
||||
source: "/deployer.thirdweb.eth/:path*",
|
||||
destination: "/thirdweb.eth/:path*",
|
||||
permanent: true,
|
||||
source: "/deployer.thirdweb.eth/:path*",
|
||||
},
|
||||
{
|
||||
source: "/chains",
|
||||
destination: "/chainlist",
|
||||
permanent: true,
|
||||
source: "/chains",
|
||||
},
|
||||
// polygon zkevm beta to non-beta
|
||||
{
|
||||
source: "/polygon-zkevm-beta",
|
||||
destination: "/polygon-zkevm",
|
||||
permanent: false,
|
||||
source: "/polygon-zkevm-beta",
|
||||
},
|
||||
// backwards compat: page moved to pages/settings/devices
|
||||
|
||||
{
|
||||
source: "/template/nft-drop",
|
||||
destination: "/template/erc721",
|
||||
permanent: false,
|
||||
source: "/template/nft-drop",
|
||||
},
|
||||
{
|
||||
destination: "/team",
|
||||
permanent: false,
|
||||
source: "/create-api-key",
|
||||
destination: "/team",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
destination: "/team",
|
||||
permanent: false,
|
||||
source: "/dashboard/settings",
|
||||
destination: "/team",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/dashboard/connect/playground",
|
||||
destination: "https://playground.thirdweb.com/connect/sign-in/button",
|
||||
permanent: false,
|
||||
source: "/dashboard/connect/playground",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/infrastructure/storage",
|
||||
destination: "/dashboard/settings/storage",
|
||||
permanent: false,
|
||||
source: "/dashboard/infrastructure/storage",
|
||||
},
|
||||
{
|
||||
source: "/dashboard/infrastructure/rpc-edge",
|
||||
destination: "/chainlist",
|
||||
permanent: false,
|
||||
source: "/dashboard/infrastructure/rpc-edge",
|
||||
},
|
||||
{
|
||||
source: "/solutions/commerce",
|
||||
destination: "/solutions/loyalty",
|
||||
permanent: false,
|
||||
source: "/solutions/commerce",
|
||||
},
|
||||
{
|
||||
source: "/hackathon/base-consumer-crypto",
|
||||
destination: "/hackathon/consumer-crypto",
|
||||
permanent: false,
|
||||
source: "/hackathon/base-consumer-crypto",
|
||||
},
|
||||
{
|
||||
source: "/bear-market-airdrop",
|
||||
destination: "/",
|
||||
permanent: false,
|
||||
source: "/bear-market-airdrop",
|
||||
},
|
||||
{
|
||||
source: "/drops/optimism",
|
||||
destination: "/optimism",
|
||||
permanent: false,
|
||||
source: "/drops/optimism",
|
||||
},
|
||||
// Redirecting as ambassadors lives in community now
|
||||
{
|
||||
source: "/ambassadors",
|
||||
destination: "/community/ambassadors",
|
||||
permanent: false,
|
||||
source: "/ambassadors",
|
||||
},
|
||||
{
|
||||
source: "/embedded-wallets",
|
||||
destination: "/in-app-wallets",
|
||||
permanent: false,
|
||||
source: "/embedded-wallets",
|
||||
},
|
||||
// temporarily redirect cli login to support page
|
||||
{
|
||||
source: "/cli/login",
|
||||
destination:
|
||||
"https://support.thirdweb.com/troubleshooting-errors/7Y1BqKNvtLdBv5fZkRZZB3/issue-linking-device-on-the-authorization-page-via-thirdweb-cli/cn9LRA3ax7XCP6uxwRYdvx",
|
||||
"https://portal.thirdweb.com/knowledge-base/onchain-common-errors/thirdweb-cli/device-link-error",
|
||||
permanent: false,
|
||||
source: "/cli/login",
|
||||
},
|
||||
|
||||
// temporary redirect gas -> explore page
|
||||
{
|
||||
source: "/gas",
|
||||
destination: "/explore",
|
||||
permanent: false,
|
||||
source: "/gas",
|
||||
},
|
||||
{
|
||||
destination: "/contracts/deployment-tool",
|
||||
permanent: false,
|
||||
source: "/deploy",
|
||||
destination: "/contracts/deployment-tool",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
destination: "/contracts/deployment-tool",
|
||||
permanent: false,
|
||||
source: "/publish",
|
||||
destination: "/contracts/deployment-tool",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/smart-contracts",
|
||||
destination: "/contracts/explore",
|
||||
permanent: false,
|
||||
source: "/smart-contracts",
|
||||
},
|
||||
{
|
||||
source: "/build",
|
||||
destination: "/contracts/modular-contracts",
|
||||
destination: "/sdk",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/ui-components",
|
||||
destination: "/sdk",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
destination: "/sdk",
|
||||
permanent: false,
|
||||
source: "/interact",
|
||||
destination: "/sdk",
|
||||
permanent: false,
|
||||
},
|
||||
{
|
||||
source: "/sponsored-transactions",
|
||||
destination: "/account-abstraction",
|
||||
permanent: false,
|
||||
source: "/sponsored-transactions",
|
||||
},
|
||||
// redirect /solutions/chains to /solutions/ecosystem
|
||||
{
|
||||
source: "/solutions/chains",
|
||||
destination: "/solutions/ecosystem",
|
||||
permanent: false,
|
||||
source: "/solutions/chains",
|
||||
},
|
||||
// redirect /rpc to portal
|
||||
{
|
||||
source: "/rpc-edge",
|
||||
destination:
|
||||
"https://portal.thirdweb.com/infrastructure/rpc-edge/overview",
|
||||
permanent: false,
|
||||
source: "/rpc-edge",
|
||||
},
|
||||
// redirect /sdk to portal
|
||||
{
|
||||
source: "/sdk",
|
||||
destination: "https://portal.thirdweb.com/connect/blockchain-api",
|
||||
permanent: false,
|
||||
source: "/sdk",
|
||||
},
|
||||
// redirect `/events` to homepage
|
||||
{
|
||||
source: "/events",
|
||||
destination: "/",
|
||||
permanent: false,
|
||||
source: "/events",
|
||||
},
|
||||
// redirect /community to /community/ambassadors
|
||||
{
|
||||
source: "/community",
|
||||
destination: "/community/ambassadors",
|
||||
permanent: false,
|
||||
source: "/community",
|
||||
},
|
||||
// redirect `/tos` to `/terms`
|
||||
{
|
||||
source: "/tos",
|
||||
destination: "/terms",
|
||||
permanent: false,
|
||||
source: "/tos",
|
||||
},
|
||||
// redirect `/privacy` to `/privacy-policy`
|
||||
{
|
||||
source: "/privacy",
|
||||
destination: "/privacy-policy",
|
||||
permanent: false,
|
||||
source: "/privacy",
|
||||
},
|
||||
// redirect `/mission` to `/home`
|
||||
{
|
||||
source: "/mission",
|
||||
destination: "/home",
|
||||
permanent: false,
|
||||
source: "/mission",
|
||||
},
|
||||
// redirect "/open-source" to "/bounties"
|
||||
{
|
||||
source: "/open-source",
|
||||
destination: "/bounties",
|
||||
permanent: false,
|
||||
source: "/open-source",
|
||||
},
|
||||
// redirect /template/<slug> to /templates/<slug>
|
||||
{
|
||||
source: "/template/:slug",
|
||||
destination: "/templates/:slug",
|
||||
permanent: false,
|
||||
source: "/template/:slug",
|
||||
},
|
||||
// redirect /account-abstraction to /connect/account-abstraction
|
||||
// redirect /connect/pay to /universal-bridge
|
||||
{
|
||||
source: "/account-abstraction",
|
||||
destination: "/connect/account-abstraction",
|
||||
destination: "/universal-bridge",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /connect/pay to /connect/universal-bridge
|
||||
{
|
||||
source: "/connect/pay",
|
||||
destination: "/connect/universal-bridge",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /auth to /connect/auth
|
||||
{
|
||||
source: "/auth",
|
||||
destination: "/connect/auth",
|
||||
permanent: false,
|
||||
},
|
||||
// redirect /in-app-wallets to /connect/in-app-wallets
|
||||
{
|
||||
source: "/in-app-wallets",
|
||||
destination: "/connect/in-app-wallets",
|
||||
permanent: false,
|
||||
},
|
||||
// PREVIOUS CAMPAIGNS
|
||||
{
|
||||
source: "/unlimited-wallets",
|
||||
destination: "/",
|
||||
permanent: false,
|
||||
source: "/unlimited-wallets",
|
||||
},
|
||||
// pay > universal-bridge redirect
|
||||
{
|
||||
source: "/team/:team_slug/:project_slug/connect/pay/:path*",
|
||||
destination:
|
||||
"/team/:team_slug/:project_slug/connect/universal-bridge/:path*",
|
||||
permanent: false,
|
||||
},
|
||||
|
||||
// all /learn/tutorials (and sub-routes) -> /learn/guides
|
||||
{
|
||||
source: "/learn/tutorials/:path*",
|
||||
destination: "/learn/guides/:path*",
|
||||
permanent: false,
|
||||
source: "/learn/tutorials/:path*",
|
||||
},
|
||||
{
|
||||
source: "/learn/tutorials",
|
||||
destination: "/learn/guides",
|
||||
permanent: false,
|
||||
source: "/learn/tutorials",
|
||||
},
|
||||
// redirect to /grant/superchain to /superchain
|
||||
{
|
||||
source: "/grant/superchain",
|
||||
destination: "/superchain",
|
||||
permanent: false,
|
||||
source: "/grant/superchain",
|
||||
},
|
||||
// connect -> build redirects
|
||||
{
|
||||
destination: "/wallets",
|
||||
permanent: false,
|
||||
source: "/connect",
|
||||
},
|
||||
{
|
||||
destination: "/account-abstraction",
|
||||
permanent: false,
|
||||
source: "/connect/account-abstraction",
|
||||
},
|
||||
{
|
||||
destination: "/universal-bridge",
|
||||
permanent: false,
|
||||
source: "/connect/universal-bridge",
|
||||
},
|
||||
{
|
||||
destination: "/auth",
|
||||
permanent: false,
|
||||
source: "/connect/auth",
|
||||
},
|
||||
{
|
||||
destination: "/in-app-wallets",
|
||||
permanent: false,
|
||||
source: "/connect/in-app-wallets",
|
||||
},
|
||||
{
|
||||
destination: "/transactions",
|
||||
permanent: false,
|
||||
source: "/engine",
|
||||
},
|
||||
|
||||
...legacyDashboardToTeamRedirects,
|
||||
...projectPageRedirects,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export async function acceptInvite(options: {
|
||||
teamId: string;
|
||||
@@ -11,20 +11,20 @@ export async function acceptInvite(options: {
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/invites/${options.inviteId}/accept`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -42,8 +42,8 @@ export async function acceptInvite(options: {
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export async function reSubscribePlan(options: {
|
||||
teamId: string;
|
||||
@@ -16,12 +16,12 @@ export async function reSubscribePlan(options: {
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}/checkout/resubscribe-plan`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export async function confirmEmailWithOTP(otp: string) {
|
||||
const token = await getAuthToken();
|
||||
@@ -15,14 +15,14 @@ export async function confirmEmailWithOTP(otp: string) {
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/account/confirmEmail`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
confirmationToken: otp,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -3,62 +3,59 @@ import "server-only";
|
||||
|
||||
// biome-ignore lint/style/useNodejsImportProtocol: breaks storybook if it's `node:` prefixed
|
||||
import { randomBytes } from "crypto";
|
||||
import type { Team } from "@/api/team";
|
||||
import { format } from "date-fns";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import type { Team } from "@/api/team";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export async function createTeam(options?: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
}) {
|
||||
export async function createTeam(options?: { name?: string; slug?: string }) {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
billingEmail: null,
|
||||
image: null,
|
||||
name:
|
||||
options?.name ?? `Your Projects ${format(new Date(), "MMM d yyyy")}`,
|
||||
slug: options?.slug ?? randomBytes(20).toString("hex"),
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name:
|
||||
options?.name ?? `Your Projects ${format(new Date(), "MMM d yyyy")}`,
|
||||
slug: options?.slug ?? randomBytes(20).toString("hex"),
|
||||
billingEmail: null,
|
||||
image: null,
|
||||
}),
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
console.error("failed to create team", {
|
||||
status: res.status,
|
||||
reason,
|
||||
status: res.status,
|
||||
});
|
||||
switch (res.status) {
|
||||
case 400: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "Invalid team name or slug.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
case 401: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "You are not authorized to perform this action.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "An unknown error occurred.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
@@ -69,7 +66,7 @@ export async function createTeam(options?: {
|
||||
};
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data: json.result,
|
||||
status: "success",
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -1,65 +1,63 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export async function deleteTeam(options: {
|
||||
teamId: string;
|
||||
}) {
|
||||
export async function deleteTeam(options: { teamId: string }) {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "You are not authorized to perform this action.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${options.teamId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
// handle errors
|
||||
if (!res.ok) {
|
||||
const reason = await res.text();
|
||||
console.error("failed to delete team", {
|
||||
status: res.status,
|
||||
reason,
|
||||
status: res.status,
|
||||
});
|
||||
switch (res.status) {
|
||||
case 400: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "Invalid team ID.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
case 401: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "You are not authorized to perform this action.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
case 403: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "You do not have permission to delete this team.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
case 404: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "Team not found.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
status: "error",
|
||||
errorMessage: "An unknown error occurred.",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use server";
|
||||
import { defineDashboardChain } from "lib/defineDashboardChain";
|
||||
import { ZERO_ADDRESS, isAddress, toTokens } from "thirdweb";
|
||||
import { isAddress, toTokens, ZERO_ADDRESS } from "thirdweb";
|
||||
import { getWalletBalance } from "thirdweb/wallets";
|
||||
import { MORALIS_API_KEY } from "../constants/server-envs";
|
||||
import { serverThirdwebClient } from "../constants/thirdweb-client.server";
|
||||
import { MORALIS_API_KEY } from "@/constants/server-envs";
|
||||
import { serverThirdwebClient } from "@/constants/thirdweb-client.server";
|
||||
import { defineDashboardChain } from "@/lib/defineDashboardChain";
|
||||
|
||||
type BalanceQueryResponse = Array<{
|
||||
balance: string;
|
||||
@@ -43,12 +43,12 @@ export async function getTokenBalancesFromMoralis(params: {
|
||||
});
|
||||
return [
|
||||
{
|
||||
token_address: ZERO_ADDRESS,
|
||||
symbol: balance.symbol,
|
||||
name: "Native Token",
|
||||
decimals: balance.decimals,
|
||||
balance: balance.value.toString(),
|
||||
decimals: balance.decimals,
|
||||
display_balance: toTokens(balance.value, balance.decimals),
|
||||
name: "Native Token",
|
||||
symbol: balance.symbol,
|
||||
token_address: ZERO_ADDRESS,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -59,10 +59,10 @@ export async function getTokenBalancesFromMoralis(params: {
|
||||
const tokenBalanceEndpoint = `https://deep-index.moralis.io/api/v2/${_address}/erc20?chain=${_chain}`;
|
||||
|
||||
const resp = await fetch(tokenBalanceEndpoint, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-api-key": MORALIS_API_KEY,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -83,7 +83,7 @@ export async function getTokenBalancesFromMoralis(params: {
|
||||
]);
|
||||
|
||||
return {
|
||||
error: undefined,
|
||||
data: [...nativeBalance, ...tokenBalances],
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "@/constants/public-envs";
|
||||
import { MORALIS_API_KEY } from "@/constants/server-envs";
|
||||
import {
|
||||
generateAlchemyUrl,
|
||||
transformAlchemyResponseToNFT,
|
||||
} from "lib/wallet/nfts/alchemy";
|
||||
} from "@/lib/wallet/nfts/alchemy";
|
||||
import {
|
||||
generateMoralisUrl,
|
||||
transformMoralisResponseToNFT,
|
||||
} from "lib/wallet/nfts/moralis";
|
||||
import type { WalletNFT } from "lib/wallet/nfts/types";
|
||||
import { getVercelEnv } from "../../lib/vercel-utils";
|
||||
import { isAlchemySupported } from "../../lib/wallet/nfts/isAlchemySupported";
|
||||
import { isMoralisSupported } from "../../lib/wallet/nfts/isMoralisSupported";
|
||||
import { NEXT_PUBLIC_DASHBOARD_CLIENT_ID } from "../constants/public-envs";
|
||||
import { MORALIS_API_KEY } from "../constants/server-envs";
|
||||
} from "@/lib/wallet/nfts/moralis";
|
||||
import type { WalletNFT } from "@/lib/wallet/nfts/types";
|
||||
import { getVercelEnv } from "@/utils/vercel";
|
||||
import { isAlchemySupported } from "../lib/wallet/nfts/isAlchemySupported";
|
||||
import { isMoralisSupported } from "../lib/wallet/nfts/isMoralisSupported";
|
||||
|
||||
type WalletNFTApiReturn =
|
||||
| { result: WalletNFT[]; error?: undefined }
|
||||
@@ -53,7 +53,7 @@ export async function getWalletNFTs(params: {
|
||||
const parsedResponse = await response.json();
|
||||
const result = await transformAlchemyResponseToNFT(parsedResponse, owner);
|
||||
|
||||
return { result, error: undefined };
|
||||
return { error: undefined, result };
|
||||
} catch (err) {
|
||||
console.error("Error fetching NFTs", err);
|
||||
return { error: "error parsing response" };
|
||||
@@ -64,10 +64,10 @@ export async function getWalletNFTs(params: {
|
||||
const url = generateMoralisUrl({ chainId, owner });
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"X-API-Key": MORALIS_API_KEY,
|
||||
},
|
||||
method: "GET",
|
||||
next: {
|
||||
revalidate: 10, // cache for 10 seconds
|
||||
},
|
||||
@@ -156,8 +156,8 @@ async function getWalletNFTsFromInsight(params: {
|
||||
if (!response.ok) {
|
||||
const errorMessage = await response.text();
|
||||
return {
|
||||
ok: false,
|
||||
error: errorMessage,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -172,32 +172,32 @@ async function getWalletNFTsFromInsight(params: {
|
||||
|
||||
const walletNFTs = nftsResponse.data.map((nft) => {
|
||||
const walletNFT: WalletNFT = {
|
||||
id: nft.token_id,
|
||||
chainId: nft.contract.chain_id,
|
||||
contractAddress: nft.contract.address,
|
||||
id: nft.token_id,
|
||||
metadata: {
|
||||
uri: isDev
|
||||
? nft.metadata_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.metadata_url,
|
||||
name: nft.name,
|
||||
description: nft.description,
|
||||
image: isDev
|
||||
? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.image_url,
|
||||
animation_url: isDev
|
||||
? nft.extra_metadata.animation_original_url?.replace(
|
||||
"ipfscdn.io/",
|
||||
"thirdwebstorage-dev.com/",
|
||||
)
|
||||
: nft.extra_metadata.animation_original_url,
|
||||
external_url: nft.external_url,
|
||||
background_color: nft.background_color,
|
||||
description: nft.description,
|
||||
external_url: nft.external_url,
|
||||
image: isDev
|
||||
? nft.image_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.image_url,
|
||||
name: nft.name,
|
||||
uri: isDev
|
||||
? nft.metadata_url.replace("ipfscdn.io/", "thirdwebstorage-dev.com/")
|
||||
: nft.metadata_url,
|
||||
},
|
||||
owner: params.owner,
|
||||
tokenURI: nft.metadata_url,
|
||||
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
|
||||
supply: nft.balance,
|
||||
tokenAddress: nft.contract.address,
|
||||
chainId: nft.contract.chain_id,
|
||||
tokenURI: nft.metadata_url,
|
||||
type: nft.token_type === "erc721" ? "ERC721" : "ERC1155",
|
||||
};
|
||||
|
||||
return walletNFT;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import {
|
||||
NEXT_PUBLIC_ENGINE_CLOUD_URL,
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
} from "../constants/public-envs";
|
||||
import { ANALYTICS_SERVICE_URL } from "../constants/server-envs";
|
||||
} from "@/constants/public-envs";
|
||||
import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs";
|
||||
|
||||
type ProxyActionParams = {
|
||||
pathname: string;
|
||||
@@ -47,35 +47,35 @@ async function proxy<T>(
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: params.method,
|
||||
body: params.body,
|
||||
headers: {
|
||||
...params.headers,
|
||||
...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
|
||||
},
|
||||
body: params.body,
|
||||
method: params.method,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const errorMessage = await res.text();
|
||||
return {
|
||||
status: res.status,
|
||||
ok: false,
|
||||
error: errorMessage || res.statusText,
|
||||
ok: false,
|
||||
status: res.status,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
status: res.status,
|
||||
ok: false,
|
||||
error: res.statusText,
|
||||
ok: false,
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
ok: true,
|
||||
data: params.parseAsText ? await res.text() : await res.json(),
|
||||
ok: true,
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export async function sendTeamInvites(options: {
|
||||
teamId: string;
|
||||
@@ -20,8 +20,8 @@ export async function sendTeamInvites(options: {
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: "You are not authorized to perform this action",
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,15 +43,15 @@ async function sendInvite(
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamId}/invites`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inviteEmail: invite.email,
|
||||
inviteRole: invite.role,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -59,8 +59,8 @@ async function sendInvite(
|
||||
const errorMessage = await res.text();
|
||||
return {
|
||||
email: invite.email,
|
||||
ok: false,
|
||||
errorMessage,
|
||||
ok: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import Stripe from "stripe";
|
||||
import type { Team } from "../api/team";
|
||||
import { STRIPE_SECRET_KEY } from "../constants/server-envs";
|
||||
import type { Team } from "@/api/team";
|
||||
import { STRIPE_SECRET_KEY } from "@/constants/server-envs";
|
||||
|
||||
let existingStripe: Stripe | undefined;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
|
||||
export async function updateAccount(values: {
|
||||
name?: string;
|
||||
@@ -14,12 +14,12 @@ export async function updateAccount(values: {
|
||||
}
|
||||
|
||||
const res = await fetch(`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/account`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Analytics Guidelines
|
||||
|
||||
This folder centralises the **PostHog** tracking logic for the dashboard app.
|
||||
Most developers will only need to add or extend _event-reporting_ functions in `report.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 1. When to add an event
|
||||
1. Ask yourself if the data will be **actionable**. Every event should have a clear product or business question it helps answer.
|
||||
2. Check if a similar event already exists in `report.ts`. Avoid duplicates.
|
||||
|
||||
---
|
||||
|
||||
## 2. Naming conventions
|
||||
| Concept | Convention | Example |
|
||||
|---------|------------|---------|
|
||||
| **Event name** (string sent to PostHog) | Human-readable phrase formatted as `<subject> <verb>` | `"contract deployed"` |
|
||||
| **Reporting function** | `report<Subject><Verb>` (PascalCase) | `reportContractDeployed` |
|
||||
| **File** | All event functions live in the shared `report.ts` file (for now) | — |
|
||||
|
||||
> Keeping names predictable makes it easy to search both code and analytics.
|
||||
|
||||
---
|
||||
|
||||
## 3. Boilerplate / template
|
||||
Add a new function to `report.ts` following this pattern:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - _Add bullet points explaining the product metrics/questions this event answers._
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @your-github-handle
|
||||
*/
|
||||
export function reportExampleEvent(properties: {
|
||||
/* Add typed properties here */
|
||||
}) {
|
||||
posthog.capture("example event", {
|
||||
/* Pass the same properties here */
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
1. **Explain the "why".** The JSDoc block is mandatory so future contributors know the purpose.
|
||||
2. **Type everything.** The `properties` object should be fully typed—this doubles as documentation.
|
||||
3. **Client-side only.** `posthog-js` must never run on the server. Call these reporting helpers from client components, event handlers, etc.
|
||||
|
||||
---
|
||||
|
||||
## 4. Editing or removing events
|
||||
1. Update both the function and the PostHog event definition (if required).
|
||||
2. Inform the core services team before removing or renaming an event.
|
||||
|
||||
---
|
||||
|
||||
## 5. Identification & housekeeping (FYI)
|
||||
Most devs can ignore this section, but for completeness:
|
||||
|
||||
- `hooks/identify-account.ts` and `hooks/identify-team.ts` wrap `posthog.identify`/`group` calls.
|
||||
- `resetAnalytics` clears identity state (used on logout).
|
||||
|
||||
---
|
||||
|
||||
## 6. Need help?
|
||||
Ping #eng-core-services in slack.
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useIdentifyAccount(opts?: {
|
||||
accountId: string;
|
||||
email?: string;
|
||||
}) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// if no accountId, don't identify
|
||||
if (!opts?.accountId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if email is provided, add it to the identify
|
||||
posthog.identify(opts.accountId, opts.email ? { email: opts.email } : {});
|
||||
}, [opts?.accountId, opts?.email]);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useIdentifyTeam(opts?: { teamId: string }) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
// if no teamId, don't identify
|
||||
if (!opts?.teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// identify the team
|
||||
posthog.group("team", opts.teamId);
|
||||
}, [opts?.teamId]);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import posthog from "posthog-js";
|
||||
|
||||
import type { Team } from "@/api/team";
|
||||
|
||||
// ----------------------------
|
||||
// CONTRACTS
|
||||
// ----------------------------
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of contracts deployed
|
||||
* - To track the number of contracts deployed on each chain
|
||||
* - To track if the contract was deployed on the token page vs on the deploy page
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportContractDeployed(properties: {
|
||||
address: string;
|
||||
chainId: number;
|
||||
publisher: string | undefined;
|
||||
contractName: string | undefined;
|
||||
deploymentType?: "asset";
|
||||
}) {
|
||||
posthog.capture("contract deployed", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of contracts that failed to deploy
|
||||
* - To track the error message of the failed contract deployment (so we can fix it / add workarounds)
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportContractDeployFailed(properties: {
|
||||
errorMessage: string;
|
||||
chainId: number;
|
||||
publisher: string | undefined;
|
||||
contractName: string | undefined;
|
||||
}) {
|
||||
posthog.capture("contract deploy failed", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of contracts published
|
||||
* - To understand the type of contracts published
|
||||
* - To understand who publishes contracts
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportContractPublished(properties: {
|
||||
publisher: string;
|
||||
contractName: string;
|
||||
version: string;
|
||||
deployType: string | undefined;
|
||||
}) {
|
||||
posthog.capture("contract published", properties);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// ONBOARDING (TEAM)
|
||||
// ----------------------------
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that enter the onboarding flow
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportOnboardingStarted() {
|
||||
posthog.capture("onboarding started");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that select a paid plan during onboarding
|
||||
* - To know **which** plan was selected
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportOnboardingPlanSelected(properties: {
|
||||
plan: Team["billingPlan"];
|
||||
}) {
|
||||
posthog.capture("onboarding plan selected", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that skip the plan-selection step during onboarding
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportOnboardingPlanSelectionSkipped() {
|
||||
posthog.capture("onboarding plan selection skipped");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that invite members during onboarding
|
||||
* - To track **how many** members were invited
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportOnboardingMembersInvited(properties: { count: number }) {
|
||||
posthog.capture("onboarding members invited", {
|
||||
count: properties.count,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that skip inviting members during onboarding
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportOnboardingMembersSkipped() {
|
||||
posthog.capture("onboarding members skipped");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track how many teams click the upsell (upgrade) button on the member-invite step during onboarding
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportOnboardingMembersUpsellButtonClicked() {
|
||||
posthog.capture("onboarding members upsell clicked");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track which plan is selected from the members-step upsell during onboarding
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportOnboardingMembersUpsellPlanSelected(properties: {
|
||||
plan: Team["billingPlan"];
|
||||
}) {
|
||||
posthog.capture("onboarding members upsell plan selected", properties);
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the number of teams that completed onboarding
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportOnboardingCompleted() {
|
||||
posthog.capture("onboarding completed");
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// FAUCET
|
||||
// ----------------------------
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track which chain the faucet was used on
|
||||
* - To track how popular specific faucets are
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportFaucetUsed(properties: { chainId: number }) {
|
||||
posthog.capture("faucet used", {
|
||||
chainId: properties.chainId,
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// CHAIN CONFIGURATION
|
||||
// ----------------------------
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track which custom chains customers are adding that we may want to add to the app
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @jnsdls
|
||||
*
|
||||
*/
|
||||
export function reportChainConfigurationAdded(properties: {
|
||||
chainId: number;
|
||||
chainName: string;
|
||||
rpcURLs: readonly string[];
|
||||
nativeCurrency: {
|
||||
name: string;
|
||||
symbol: string;
|
||||
decimals: number;
|
||||
};
|
||||
}) {
|
||||
posthog.capture("chain configuration added", {
|
||||
chainId: properties.chainId,
|
||||
chainName: properties.chainName,
|
||||
nativeCurrency: properties.nativeCurrency,
|
||||
rpcURLs: properties.rpcURLs,
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// ASSETS
|
||||
// ----------------------------
|
||||
|
||||
type AssetContractType = "DropERC20" | "DropERC1155" | "DropERC721";
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of successful asset purchases from the token page
|
||||
* - To track which asset and contract types are being purchased the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetBuySuccessful(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType;
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset buy successful", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of failed asset purchases from the token page
|
||||
* - To track the errors that users encounter when trying to purchase an asset
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetBuyFailed(properties: {
|
||||
chainId: number;
|
||||
contractType: AssetContractType;
|
||||
assetType: "nft" | "coin";
|
||||
error: string;
|
||||
}) {
|
||||
posthog.capture("asset buy failed", {
|
||||
assetType: properties.assetType,
|
||||
chainId: properties.chainId,
|
||||
contractType: properties.contractType,
|
||||
error: properties.error,
|
||||
});
|
||||
}
|
||||
|
||||
// Assets Landing Page ----------------------------
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of asset creation started from the assets page
|
||||
* - To track which asset types are being created the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetCreationStarted(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
}) {
|
||||
posthog.capture("asset creation started", {
|
||||
assetType: properties.assetType,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of assets imported successfully from the assets page
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetImportSuccessful() {
|
||||
posthog.capture("asset import successful");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of asset import started in the assets page
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetImportStarted() {
|
||||
posthog.capture("asset import started");
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track the steps users are configuring in the asset creation to understand if there are any drop-offs
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetCreationStepConfigured(
|
||||
properties:
|
||||
| {
|
||||
assetType: "nft";
|
||||
step: "collection-info" | "upload-assets" | "sales-settings";
|
||||
}
|
||||
| {
|
||||
assetType: "coin";
|
||||
step: "coin-info" | "token-distribution" | "launch-coin";
|
||||
},
|
||||
) {
|
||||
posthog.capture("asset creation step configured", {
|
||||
assetType: properties.assetType,
|
||||
step: properties.step,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of successful asset creations
|
||||
* - To track which asset types are being created the most
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetCreationSuccessful(properties: {
|
||||
assetType: "nft" | "coin";
|
||||
contractType: AssetContractType;
|
||||
}) {
|
||||
posthog.capture("asset creation successful", {
|
||||
assetType: properties.assetType,
|
||||
contractType: properties.contractType,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ### Why do we need to report this event?
|
||||
* - To track number of failed asset creations
|
||||
* - To track the errors that users encounter when trying to create an asset
|
||||
* - To track the step that is failing in the asset creation
|
||||
*
|
||||
* ### Who is responsible for this event?
|
||||
* @MananTank
|
||||
*/
|
||||
export function reportAssetCreationFailed(
|
||||
properties: { contractType: AssetContractType; error: string } & (
|
||||
| {
|
||||
assetType: "nft";
|
||||
step: "deploy-contract" | "mint-nfts" | "set-claim-conditions";
|
||||
}
|
||||
| {
|
||||
assetType: "coin";
|
||||
step:
|
||||
| "deploy-contract"
|
||||
| "set-claim-conditions"
|
||||
| "mint-tokens"
|
||||
| "airdrop-tokens";
|
||||
}
|
||||
),
|
||||
) {
|
||||
posthog.capture("asset creation failed", {
|
||||
assetType: properties.assetType,
|
||||
contractType: properties.contractType,
|
||||
error: properties.error,
|
||||
step: properties.step,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import posthog from "posthog-js";
|
||||
|
||||
export function resetAnalytics() {
|
||||
posthog.reset();
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
|
||||
import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs";
|
||||
import type {
|
||||
AnalyticsQueryParams,
|
||||
EcosystemWalletStats,
|
||||
@@ -12,9 +13,8 @@ import type {
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
WalletUserStats,
|
||||
} from "types/analytics";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { ANALYTICS_SERVICE_URL } from "../constants/server-envs";
|
||||
} from "@/types/analytics";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
import { getChains } from "./chain";
|
||||
|
||||
async function fetchAnalytics(
|
||||
@@ -175,9 +175,9 @@ export async function getAggregateUserOpUsage(
|
||||
},
|
||||
{
|
||||
date: (params.from || new Date()).toISOString(),
|
||||
successful: 0,
|
||||
failed: 0,
|
||||
sponsoredUsd: 0,
|
||||
successful: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -279,14 +279,14 @@ export async function isProjectActive(params: {
|
||||
);
|
||||
return {
|
||||
bundler: false,
|
||||
storage: false,
|
||||
rpc: false,
|
||||
nebula: false,
|
||||
sdk: false,
|
||||
insight: false,
|
||||
pay: false,
|
||||
inAppWallet: false,
|
||||
ecosystemWallet: false,
|
||||
inAppWallet: false,
|
||||
insight: false,
|
||||
nebula: false,
|
||||
pay: false,
|
||||
rpc: false,
|
||||
sdk: false,
|
||||
storage: false,
|
||||
} as ActiveStatus;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type AuditLogEntry = {
|
||||
who: {
|
||||
@@ -58,34 +58,34 @@ export async function getAuditLogs(teamSlug: string, cursor?: string) {
|
||||
url.searchParams.set("take", "15");
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
next: {
|
||||
// revalidate this query once per 10 seconds (does not need to be more granular than that)
|
||||
revalidate: 10,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
// if the status is 402, the most likely reason is that the team is on a free plan
|
||||
if (response.status === 402) {
|
||||
return {
|
||||
status: "error",
|
||||
reason: "higher_plan_required",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as AuditLogApiResponse;
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data,
|
||||
status: "success",
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
import { LAST_USED_TEAM_ID } from "constants/cookies";
|
||||
import { cookies } from "next/headers";
|
||||
import { COOKIE_ACTIVE_ACCOUNT, COOKIE_PREFIX_TOKEN } from "@/constants/cookie";
|
||||
import { LAST_USED_TEAM_ID } from "@/constants/cookies";
|
||||
import { getClientThirdwebClient } from "@/constants/thirdweb-client.client";
|
||||
|
||||
export async function getAuthToken() {
|
||||
const cookiesManager = await cookies();
|
||||
@@ -1,8 +1,8 @@
|
||||
import "server-only";
|
||||
import type { ChainMetadata } from "thirdweb/chains";
|
||||
import type { ChainService } from "../../app/(app)/(dashboard)/(chain)/types/chain";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "../constants/server-envs";
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export async function createDedicatedSupportChannel(
|
||||
teamIdOrSlug: string,
|
||||
channelType: "slack" | "telegram",
|
||||
): Promise<{ error: string | null }> {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
new URL(
|
||||
`/v1/teams/${teamIdOrSlug}/dedicated-support-channel`,
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
),
|
||||
{
|
||||
body: JSON.stringify({
|
||||
type: channelType,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
return {
|
||||
error:
|
||||
json.error?.message ?? "Failed to create dedicated support channel.",
|
||||
};
|
||||
}
|
||||
return { error: null };
|
||||
}
|
||||
@@ -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,8 +1,8 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
"use server";
|
||||
|
||||
import { getAuthToken } from "app/(app)/api/lib/getAuthToken";
|
||||
import { THIRDWEB_INSIGHT_API_DOMAIN } from "constants/urls";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { THIRDWEB_INSIGHT_API_DOMAIN } from "@/constants/urls";
|
||||
|
||||
export interface WebhookResponse {
|
||||
id: string;
|
||||
@@ -80,13 +80,13 @@ export async function createWebhook(
|
||||
const response = await fetch(
|
||||
`https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -115,11 +115,11 @@ export async function getWebhooks(
|
||||
const response = await fetch(
|
||||
`https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-client-id": clientId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"x-client-id": clientId,
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -149,11 +149,11 @@ export async function deleteWebhook(
|
||||
const response = await fetch(
|
||||
`https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks/${encodeURIComponent(webhookId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"x-client-id": clientId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"x-client-id": clientId,
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -183,29 +183,29 @@ export async function testWebhook(
|
||||
const response = await fetch(
|
||||
`https://${THIRDWEB_INSIGHT_API_DOMAIN}/v1/webhooks/test`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": clientId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to test webhook: ${errorText}`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
return (await response.json()) as TestWebhookResponse;
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Network or parsing error: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -215,10 +215,10 @@ export async function getSupportedWebhookChains(): Promise<SupportedWebhookChain
|
||||
const response = await fetch(
|
||||
`https://${THIRDWEB_INSIGHT_API_DOMAIN}/service/chains`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-service-api-key": process.env.INSIGHT_SERVICE_API_KEY || "",
|
||||
},
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type LinkedWallet = {
|
||||
createdAt: string;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type Notification = {
|
||||
id: string;
|
||||
@@ -41,17 +41,17 @@ export async function getUnreadNotifications(cursor?: string) {
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as NotificationsApiResponse;
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data,
|
||||
status: "success",
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -77,17 +77,17 @@ export async function getArchivedNotifications(cursor?: string) {
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as NotificationsApiResponse;
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data,
|
||||
status: "success",
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -109,9 +109,9 @@ export async function getUnreadNotificationsCount() {
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
@@ -120,8 +120,8 @@ export async function getUnreadNotificationsCount() {
|
||||
};
|
||||
};
|
||||
return {
|
||||
status: "success",
|
||||
data,
|
||||
status: "success",
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -135,20 +135,20 @@ export async function markNotificationAsRead(notificationId?: string) {
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
);
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
// if notificationId is provided, mark it as read, otherwise mark all as read
|
||||
body: JSON.stringify(notificationId ? { notificationId } : {}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
// if notificationId is provided, mark it as read, otherwise mark all as read
|
||||
body: JSON.stringify(notificationId ? { notificationId } : {}),
|
||||
method: "PUT",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type Project = ProjectResponse;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "../constants/server-envs";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
|
||||
export type SMSCountryTiers = {
|
||||
tier1: string[];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type TeamInvite = {
|
||||
id: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import "server-only";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
const TeamAccountRole = {
|
||||
OWNER: "OWNER",
|
||||
MEMBER: "MEMBER",
|
||||
OWNER: "OWNER",
|
||||
} as const;
|
||||
|
||||
export type TeamAccountRole =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import type { ProductSKU } from "../lib/billing";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import type { ProductSKU } from "@/types/billing";
|
||||
|
||||
type InvoiceLine = {
|
||||
// amount for this line item
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import "server-only";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import type { TeamResponse } from "@thirdweb-dev/service-utils";
|
||||
import { cookies } from "next/headers";
|
||||
import { LAST_USED_TEAM_ID } from "@/constants/cookies";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { API_SERVER_SECRET } from "@/constants/server-envs";
|
||||
import { getValidAccount } from "../../app/(app)/account/settings/getAccount";
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { LAST_USED_TEAM_ID } from "../../constants/cookies";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
import { getMemberByAccountId } from "./team-members";
|
||||
|
||||
export type Team = TeamResponse & { stripeCustomerId: string | null };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
import { getAuthToken } from "app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "../../constants/public-envs";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST } from "@/constants/public-envs";
|
||||
|
||||
const UB_BASE_URL = NEXT_PUBLIC_THIRDWEB_BRIDGE_HOST;
|
||||
|
||||
@@ -14,19 +14,16 @@ type Webhook = {
|
||||
version?: number; // TODO (UB) make this mandatory after migration
|
||||
};
|
||||
|
||||
export async function getWebhooks(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
}) {
|
||||
export async function getWebhooks(props: { clientId: string; teamId: string }) {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -49,19 +46,19 @@ export async function createWebhook(props: {
|
||||
const authToken = await getAuthToken();
|
||||
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/webhooks`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
url: props.url,
|
||||
label: props.label,
|
||||
version: props.version,
|
||||
secret: props.secret,
|
||||
url: props.url,
|
||||
version: props.version,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -81,13 +78,13 @@ export async function deleteWebhook(props: {
|
||||
const res = await fetch(
|
||||
`${UB_BASE_URL}/v1/developer/webhooks/${props.webhookId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
|
||||
@@ -106,19 +103,16 @@ export type Fee = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function getFees(props: {
|
||||
clientId: string;
|
||||
teamId: string;
|
||||
}) {
|
||||
export async function getFees(props: { clientId: string; teamId: string }) {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": props.clientId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -138,17 +132,17 @@ export async function updateFee(props: {
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/developer/fees`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-team-id": props.teamId,
|
||||
"x-client-id": props.clientId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
feeRecipient: props.feeRecipient,
|
||||
feeBps: props.feeBps,
|
||||
feeRecipient: props.feeRecipient,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
},
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -225,13 +219,13 @@ export async function getPayments(props: {
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.clientId,
|
||||
"x-team-id": props.teamId,
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -18,15 +18,13 @@ type PaymentLink = {
|
||||
purchaseData: Record<string, unknown> | undefined;
|
||||
};
|
||||
|
||||
export async function getPaymentLink(props: {
|
||||
paymentId: string;
|
||||
}) {
|
||||
export async function getPaymentLink(props: { paymentId: string }) {
|
||||
const res = await fetch(`${UB_BASE_URL}/v1/links/${props.paymentId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
},
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -36,12 +34,12 @@ export async function getPaymentLink(props: {
|
||||
|
||||
const { data } = await res.json();
|
||||
return {
|
||||
clientId: data.clientId,
|
||||
title: data.title,
|
||||
imageUrl: data.imageUrl,
|
||||
receiver: data.receiver,
|
||||
destinationToken: data.destinationToken,
|
||||
amount: data.amount ? BigInt(data.amount) : undefined,
|
||||
clientId: data.clientId,
|
||||
destinationToken: data.destinationToken,
|
||||
imageUrl: data.imageUrl,
|
||||
purchaseData: data.purchaseData,
|
||||
receiver: data.receiver,
|
||||
title: data.title,
|
||||
} as PaymentLink;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import type { ProjectResponse } from "@thirdweb-dev/service-utils";
|
||||
import { getAuthToken } from "app/(app)/api/lib/getAuthToken";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { DASHBOARD_THIRDWEB_SECRET_KEY } from "@/constants/server-envs";
|
||||
import { UB_BASE_URL } from "./constants";
|
||||
|
||||
export type TokenMetadata = {
|
||||
@@ -13,9 +13,7 @@ export type TokenMetadata = {
|
||||
iconUri?: string;
|
||||
};
|
||||
|
||||
export async function getUniversalBridgeTokens(props: {
|
||||
chainId?: number;
|
||||
}) {
|
||||
export async function getUniversalBridgeTokens(props: { chainId?: number }) {
|
||||
const url = new URL(`${UB_BASE_URL}/v1/tokens`);
|
||||
|
||||
if (props.chainId) {
|
||||
@@ -24,11 +22,11 @@ export async function getUniversalBridgeTokens(props: {
|
||||
url.searchParams.append("limit", "1000");
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-secret-key": DASHBOARD_THIRDWEB_SECRET_KEY,
|
||||
} as Record<string, string>,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -49,16 +47,16 @@ export async function addUniversalBridgeTokenRoute(props: {
|
||||
const url = new URL(`${UB_BASE_URL}/v1/tokens`);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"x-client-id": props.project.publishableKey,
|
||||
} as Record<string, string>,
|
||||
body: JSON.stringify({
|
||||
chainId: props.chainId,
|
||||
tokenAddress: props.tokenAddress,
|
||||
}),
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"x-client-id": props.project.publishableKey,
|
||||
} as Record<string, string>,
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "../../../app/(app)/api/lib/getAuthToken";
|
||||
|
||||
type LineItem = {
|
||||
quantity: number;
|
||||
@@ -34,33 +34,33 @@ export async function getBilledUsage(teamSlug: string) {
|
||||
NEXT_PUBLIC_THIRDWEB_API_HOST,
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
next: {
|
||||
// revalidate this query once per minute (does not need to be more granular than that)
|
||||
revalidate: 60,
|
||||
},
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
// if the status is 404, the most likely reason is that the team is on a free plan
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
status: "error",
|
||||
reason: "free_plan",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
const body = await response.text();
|
||||
return {
|
||||
status: "error",
|
||||
reason: "unknown",
|
||||
body,
|
||||
reason: "unknown",
|
||||
status: "error",
|
||||
} as const;
|
||||
}
|
||||
const data = (await response.json()) as UsageApiResponse;
|
||||
return {
|
||||
status: "success",
|
||||
data,
|
||||
status: "success",
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { ANALYTICS_SERVICE_URL } from "../../constants/server-envs";
|
||||
import { ANALYTICS_SERVICE_URL } from "@/constants/server-envs";
|
||||
|
||||
type Last24HoursRPCUsageApiResponse = {
|
||||
peakRate: {
|
||||
@@ -23,11 +23,7 @@ type Last24HoursRPCUsageApiResponse = {
|
||||
};
|
||||
|
||||
export const getLast24HoursRPCUsage = unstable_cache(
|
||||
async (params: {
|
||||
teamId: string;
|
||||
projectId?: string;
|
||||
authToken: string;
|
||||
}) => {
|
||||
async (params: { teamId: string; projectId?: string; authToken: string }) => {
|
||||
const analyticsEndpoint = ANALYTICS_SERVICE_URL;
|
||||
const url = new URL(`${analyticsEndpoint}/v2/rpc/24-hours`);
|
||||
url.searchParams.set("teamId", params.teamId);
|
||||
@@ -44,16 +40,16 @@ export const getLast24HoursRPCUsage = unstable_cache(
|
||||
if (!res.ok) {
|
||||
const error = await res.text();
|
||||
return {
|
||||
ok: false as const,
|
||||
error: error,
|
||||
ok: false as const,
|
||||
};
|
||||
}
|
||||
|
||||
const resData = await res.json();
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
data: resData.data as Last24HoursRPCUsageApiResponse,
|
||||
ok: true as const,
|
||||
};
|
||||
},
|
||||
["rpc-usage-last-24-hours:v2"],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { getAuthToken } from "../../app/(app)/api/lib/getAuthToken";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "../constants/public-envs";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { getAuthToken } from "./auth-token";
|
||||
|
||||
export type VerifiedDomainResponse =
|
||||
| {
|
||||
@@ -56,12 +56,12 @@ export async function createDomainVerification(
|
||||
const res = await fetch(
|
||||
`${NEXT_PUBLIC_THIRDWEB_API_HOST}/v1/teams/${teamIdOrSlug}/verified-domain`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ domain }),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { differenceInCalendarDays, format, subDays } from "date-fns";
|
||||
import { DatePickerWithRange } from "@/components/ui/DatePickerWithRange";
|
||||
import {
|
||||
Select,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { normalizeTime } from "@/lib/time";
|
||||
import { differenceInCalendarDays, format, subDays } from "date-fns";
|
||||
|
||||
export function DateRangeSelector(props: {
|
||||
range: Range;
|
||||
@@ -26,30 +26,15 @@ export function DateRangeSelector(props: {
|
||||
|
||||
return (
|
||||
<DatePickerWithRange
|
||||
className="w-auto bg-card"
|
||||
from={range.from}
|
||||
to={range.to}
|
||||
popoverAlign={props.popoverAlign}
|
||||
setFrom={(from) =>
|
||||
setRange({
|
||||
from,
|
||||
to: range.to,
|
||||
type: "custom",
|
||||
})
|
||||
}
|
||||
setTo={(to) =>
|
||||
setRange({
|
||||
from: range.from,
|
||||
to,
|
||||
type: "custom",
|
||||
})
|
||||
}
|
||||
header={
|
||||
<div className="mb-2 border-border border-b p-4">
|
||||
<Select
|
||||
value={rangeType}
|
||||
onValueChange={(id: DurationId) => {
|
||||
setRange(getLastNDaysRange(id));
|
||||
}}
|
||||
value={rangeType}
|
||||
>
|
||||
<SelectTrigger className="flex bg-transparent">
|
||||
<SelectValue placeholder="Select" />
|
||||
@@ -72,7 +57,22 @@ export function DateRangeSelector(props: {
|
||||
</div>
|
||||
}
|
||||
labelOverride={rangeLabel}
|
||||
className="w-auto bg-card"
|
||||
popoverAlign={props.popoverAlign}
|
||||
setFrom={(from) =>
|
||||
setRange({
|
||||
from,
|
||||
to: range.to,
|
||||
type: "custom",
|
||||
})
|
||||
}
|
||||
setTo={(to) =>
|
||||
setRange({
|
||||
from: range.from,
|
||||
to,
|
||||
type: "custom",
|
||||
})
|
||||
}
|
||||
to={range.to}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -86,10 +86,10 @@ export function getLastNDaysRange(id: DurationId) {
|
||||
const todayDate = new Date(Date.now() + 1000 * 60 * 60 * 24); // add 1 day to avoid timezone issues
|
||||
|
||||
const value: Range = {
|
||||
type: id,
|
||||
from: subDays(todayDate, durationInfo.days),
|
||||
to: todayDate,
|
||||
label: durationInfo.name,
|
||||
to: todayDate,
|
||||
type: id,
|
||||
};
|
||||
|
||||
return value;
|
||||
@@ -97,24 +97,24 @@ export function getLastNDaysRange(id: DurationId) {
|
||||
|
||||
const durationPresets = [
|
||||
{
|
||||
name: "Last 7 Days",
|
||||
id: "last-7",
|
||||
days: 7,
|
||||
id: "last-7",
|
||||
name: "Last 7 Days",
|
||||
},
|
||||
{
|
||||
name: "Last 30 Days",
|
||||
id: "last-30",
|
||||
days: 30,
|
||||
id: "last-30",
|
||||
name: "Last 30 Days",
|
||||
},
|
||||
{
|
||||
name: "Last 60 Days",
|
||||
id: "last-60",
|
||||
days: 60,
|
||||
id: "last-60",
|
||||
name: "Last 60 Days",
|
||||
},
|
||||
{
|
||||
name: "Last 120 Days",
|
||||
id: "last-120",
|
||||
days: 120,
|
||||
id: "last-120",
|
||||
name: "Last 120 Days",
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
import { LoadingDots } from "@/components/ui/LoadingDots";
|
||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
import { useId, useMemo } from "react";
|
||||
import { Area, AreaChart, Bar, BarChart } from "recharts";
|
||||
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
|
||||
import { LoadingDots } from "@/components/ui/LoadingDots";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FakeCartData = {
|
||||
value: number;
|
||||
@@ -17,15 +17,18 @@ function generateRandomData(): FakeCartData[] {
|
||||
|
||||
const skeletonChartConfig = {
|
||||
value: {
|
||||
label: "Value",
|
||||
color: "hsl(var(--muted)/90%)",
|
||||
label: "Value",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
export function EmptyChartState({
|
||||
children,
|
||||
type,
|
||||
}: { children?: React.ReactNode; type: "bar" | "area" }) {
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
type: "bar" | "area";
|
||||
}) {
|
||||
const barChartData = useMemo(() => generateRandomData(), []);
|
||||
|
||||
return (
|
||||
@@ -55,10 +58,11 @@ function SkeletonBarChart(props: {
|
||||
data: FakeCartData[];
|
||||
type: "bar" | "area";
|
||||
}) {
|
||||
const fillAreaSkeletonId = useId();
|
||||
return (
|
||||
<ChartContainer
|
||||
config={skeletonChartConfig}
|
||||
className="pointer-events-none h-full w-full blur-[5px]"
|
||||
config={skeletonChartConfig}
|
||||
>
|
||||
{props.type === "bar" ? (
|
||||
<BarChart
|
||||
@@ -77,7 +81,7 @@ function SkeletonBarChart(props: {
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="fill_area_skeleton" x1="0" y1="0" x2="0" y2="1">
|
||||
<linearGradient id={fillAreaSkeletonId} x1="0" x2="0" y1="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor={"hsl(var(--muted-foreground)/0.5)"}
|
||||
@@ -87,11 +91,11 @@ function SkeletonBarChart(props: {
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Area
|
||||
type="natural"
|
||||
dataKey="value"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fill="url(#fill_area_skeleton)"
|
||||
fill={`url(#${fillAreaSkeletonId})`}
|
||||
radius={8}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
type="natural"
|
||||
/>
|
||||
</AreaChart>
|
||||
)}
|
||||
@@ -14,10 +14,10 @@ export function IntervalSelector(props: {
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
value={props.intervalType}
|
||||
onValueChange={(value: "day" | "week") => {
|
||||
props.setIntervalType(value);
|
||||
}}
|
||||
value={props.intervalType}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={cn("w-auto bg-card md:w-[120px]", props.className)}
|
||||
@@ -1,22 +1,25 @@
|
||||
"use client";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
getLastNDaysRange,
|
||||
} from "components/analytics/date-range-selector";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
DurationId,
|
||||
Range,
|
||||
} from "components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "components/analytics/interval-selector";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import {
|
||||
DateRangeSelector,
|
||||
getLastNDaysRange,
|
||||
} from "@/components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "@/components/analytics/interval-selector";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
|
||||
export function RangeSelector({
|
||||
range,
|
||||
interval,
|
||||
}: { range?: Range; interval: "day" | "week" }) {
|
||||
}: {
|
||||
range?: Range;
|
||||
interval: "day" | "week";
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useDashboardRouter();
|
||||
@@ -26,7 +29,6 @@ export function RangeSelector({
|
||||
const [localInterval, setInterval] = useState<"day" | "week">(interval);
|
||||
|
||||
useQuery({
|
||||
queryKey: ["analytics-range", searchParams?.toString(), range],
|
||||
queryFn: async () => {
|
||||
if (range) {
|
||||
setRange(range);
|
||||
@@ -46,11 +48,11 @@ export function RangeSelector({
|
||||
}
|
||||
return getLastNDaysRange("last-120");
|
||||
},
|
||||
queryKey: ["analytics-range", searchParams?.toString(), range],
|
||||
});
|
||||
|
||||
// prefetch for each interval and default range
|
||||
useQuery({
|
||||
queryKey: ["analytics-range", searchParams?.toString()],
|
||||
queryFn: async () => {
|
||||
const newSearchParams = new URLSearchParams(searchParams || {});
|
||||
for (const interval of ["day", "week"] as const) {
|
||||
@@ -70,6 +72,7 @@ export function RangeSelector({
|
||||
}
|
||||
return true;
|
||||
},
|
||||
queryKey: ["analytics-range", searchParams?.toString()],
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Alert,
|
||||
AlertIcon,
|
||||
@@ -12,8 +11,10 @@ import {
|
||||
Textarea,
|
||||
} from "@chakra-ui/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { TransactionButton } from "components/buttons/TransactionButton";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { Button } from "chakra/button";
|
||||
import { FormErrorMessage, FormHelperText, FormLabel } from "chakra/form";
|
||||
import { Heading } from "chakra/heading";
|
||||
import { Text } from "chakra/text";
|
||||
import { ChevronLeftIcon, EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
@@ -21,18 +22,13 @@ import { useForm } from "react-hook-form";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { CreateDelayedRevealBatchParams } from "thirdweb/extensions/erc721";
|
||||
import type { NFTInput } from "thirdweb/utils";
|
||||
import {
|
||||
Button,
|
||||
FormErrorMessage,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Text,
|
||||
TrackedLink,
|
||||
} from "tw-components";
|
||||
import type { ComponentWithChildren } from "types/component-with-children";
|
||||
import { processInputData, shuffleData } from "utils/batch";
|
||||
import { z } from "zod";
|
||||
import { FileInput } from "@/components/blocks/FileInput";
|
||||
import { TransactionButton } from "@/components/tx-button";
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { UnderlineLink } from "@/components/ui/UnderlineLink";
|
||||
import type { ComponentWithChildren } from "@/types/component-with-children";
|
||||
import { processInputData, shuffleData } from "@/utils/batch";
|
||||
import { BatchTable } from "./batch-table";
|
||||
import { SelectOption } from "./lazy-mint-form/select-option";
|
||||
import { UploadStep } from "./upload-step";
|
||||
@@ -60,27 +56,27 @@ type BatchLazyMintProps = BatchLazyMintEVMProps;
|
||||
|
||||
const BatchLazyMintFormSchema = z
|
||||
.object({
|
||||
// delayed reveal placeholder
|
||||
placeHolder: z
|
||||
.object({
|
||||
name: z.string().min(1, "A name is required"),
|
||||
image: z.any().optional(),
|
||||
description: z.string().or(z.string().length(0)).optional(),
|
||||
})
|
||||
.optional(),
|
||||
// delayed reveal password logic
|
||||
password: z.string().min(1, "A password is required.").optional(),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(1, "Please confirm your password.")
|
||||
.optional(),
|
||||
|
||||
// shared logic
|
||||
shuffle: z.boolean().default(false),
|
||||
revealType: z.literal("instant").or(z.literal("delayed")).optional(),
|
||||
|
||||
// metadata
|
||||
metadatas: z.array(z.any()),
|
||||
// delayed reveal password logic
|
||||
password: z.string().min(1, "A password is required.").optional(),
|
||||
// delayed reveal placeholder
|
||||
placeHolder: z
|
||||
.object({
|
||||
description: z.string().or(z.string().length(0)).optional(),
|
||||
image: z.any().optional(),
|
||||
name: z.string().min(1, "A name is required"),
|
||||
})
|
||||
.optional(),
|
||||
revealType: z.literal("instant").or(z.literal("delayed")).optional(),
|
||||
|
||||
// shared logic
|
||||
shuffle: z.boolean().default(false),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match",
|
||||
@@ -93,12 +89,12 @@ type BatchLazyMintFormType = z.output<typeof BatchLazyMintFormSchema> & {
|
||||
|
||||
function useBatchLazyMintForm() {
|
||||
return useForm<BatchLazyMintFormType>({
|
||||
resolver: zodResolver(BatchLazyMintFormSchema),
|
||||
defaultValues: {
|
||||
metadatas: [],
|
||||
revealType: undefined,
|
||||
shuffle: false,
|
||||
},
|
||||
resolver: zodResolver(BatchLazyMintFormSchema),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,8 +146,8 @@ export const BatchLazyMint: ComponentWithChildren<
|
||||
// check submit is instant
|
||||
if (data.revealType === "instant") {
|
||||
return props.onSubmit({
|
||||
revealType: "instant",
|
||||
data: { metadatas: shuffledMetadatas },
|
||||
revealType: "instant",
|
||||
});
|
||||
}
|
||||
// validate password
|
||||
@@ -171,16 +167,16 @@ export const BatchLazyMint: ComponentWithChildren<
|
||||
}
|
||||
// submit
|
||||
return props.onSubmit({
|
||||
revealType: "delayed",
|
||||
data: {
|
||||
metadata: shuffledMetadatas,
|
||||
password: data.password,
|
||||
placeholderMetadata: {
|
||||
name: data.placeHolder?.name,
|
||||
description: data.placeHolder?.description,
|
||||
image: data.placeHolder?.image,
|
||||
name: data.placeHolder?.name,
|
||||
},
|
||||
},
|
||||
revealType: "delayed",
|
||||
});
|
||||
})}
|
||||
>
|
||||
@@ -189,10 +185,10 @@ export const BatchLazyMint: ComponentWithChildren<
|
||||
{nftMetadatas.length > 0 ? (
|
||||
<>
|
||||
<BatchTable
|
||||
portalRef={paginationPortalRef}
|
||||
client={props.client}
|
||||
data={nftMetadatas}
|
||||
nextTokenIdToMint={props.nextTokenIdToMint}
|
||||
client={props.client}
|
||||
portalRef={paginationPortalRef}
|
||||
/>
|
||||
<div className="border-border border-t">
|
||||
<div className="flex flex-col items-center justify-between p-0 md:flex-row md:p-4">
|
||||
@@ -222,8 +218,8 @@ export const BatchLazyMint: ComponentWithChildren<
|
||||
</>
|
||||
) : (
|
||||
<UploadStep
|
||||
getRootProps={getRootProps}
|
||||
getInputProps={getInputProps}
|
||||
getRootProps={getRootProps}
|
||||
hasFailed={hasError}
|
||||
isDragActive={isDragActive}
|
||||
/>
|
||||
@@ -231,31 +227,31 @@ export const BatchLazyMint: ComponentWithChildren<
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Flex align="center" justify="space-between" py={4} w="100%" mb={2}>
|
||||
<Flex align="center" justify="space-between" mb={2} py={4} w="100%">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Button
|
||||
className="text-muted-foreground"
|
||||
variant="ghost"
|
||||
onClick={() => setStep(0)}
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeftIcon className="size-5 cursor-pointer" />
|
||||
</Button>
|
||||
<Heading size="title.md" className="my-auto">
|
||||
<Heading className="my-auto" size="title.md">
|
||||
When will you reveal your NFTs?
|
||||
</Heading>
|
||||
</div>
|
||||
</Flex>
|
||||
<SelectReveal
|
||||
form={form}
|
||||
canCreateDelayedRevealBatch={props.canCreateDelayedRevealBatch}
|
||||
client={props.client}
|
||||
form={form}
|
||||
/>
|
||||
{form.watch("revealType") && (
|
||||
<>
|
||||
<CheckboxWithLabel>
|
||||
<Checkbox
|
||||
className="mt-3"
|
||||
checked={form.watch("shuffle")}
|
||||
className="mt-3"
|
||||
onCheckedChange={(val) => form.setValue("shuffle", !!val)}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-1 md:flex-row">
|
||||
@@ -265,14 +261,14 @@ export const BatchLazyMint: ComponentWithChildren<
|
||||
</CheckboxWithLabel>
|
||||
<div className="w-full md:w-[61%]">
|
||||
<TransactionButton
|
||||
client={props.client}
|
||||
txChainID={props.chainId}
|
||||
className="mt-4 w-full"
|
||||
transactionCount={1}
|
||||
client={props.client}
|
||||
disabled={!nftMetadatas.length}
|
||||
type="submit"
|
||||
isPending={form.formState.isSubmitting}
|
||||
isLoggedIn={props.isLoggedIn}
|
||||
isPending={form.formState.isSubmitting}
|
||||
transactionCount={1}
|
||||
txChainID={props.chainId}
|
||||
type="submit"
|
||||
>
|
||||
{form.formState.isSubmitting
|
||||
? `Uploading ${nftMetadatas.length} NFTs`
|
||||
@@ -280,15 +276,15 @@ export const BatchLazyMint: ComponentWithChildren<
|
||||
</TransactionButton>
|
||||
{props.children}
|
||||
</div>
|
||||
<Text size="body.sm" mt={2}>
|
||||
<TrackedLink
|
||||
<Text mt={2} size="body.sm">
|
||||
<UnderlineLink
|
||||
className="text-primary-500"
|
||||
href="https://support.thirdweb.com/dashboard/n5evQ4EfEjEifczEQaZ1hL/batch-upload-troubleshooting/5WMQFqfaUTU1C8NM8FtJ2X"
|
||||
isExternal
|
||||
category="batch-upload"
|
||||
label="issues"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Experiencing issues uploading your files?
|
||||
</TrackedLink>
|
||||
</UnderlineLink>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
@@ -316,30 +312,30 @@ const SelectReveal: React.FC<SelectRevealProps> = ({
|
||||
return (
|
||||
<Flex flexDir="column">
|
||||
<Flex
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
gap={{ base: 3, md: 6 }}
|
||||
mb={6}
|
||||
flexDir={{ base: "column", md: "row" }}
|
||||
>
|
||||
<SelectOption
|
||||
name="Reveal upon mint"
|
||||
description="Collectors will immediately see the final NFT when they complete the minting"
|
||||
isActive={form.watch("revealType") === "instant"}
|
||||
name="Reveal upon mint"
|
||||
onClick={() => form.setValue("revealType", "instant")}
|
||||
/>
|
||||
<SelectOption
|
||||
name="Delayed Reveal"
|
||||
description="Collectors will mint your placeholder image, then you reveal at a later time"
|
||||
isActive={form.watch("revealType") === "delayed"}
|
||||
onClick={() => form.setValue("revealType", "delayed")}
|
||||
disabled={!canCreateDelayedRevealBatch}
|
||||
disabledText="This contract doesn't implement Delayed Reveal"
|
||||
isActive={form.watch("revealType") === "delayed"}
|
||||
name="Delayed Reveal"
|
||||
onClick={() => form.setValue("revealType", "delayed")}
|
||||
/>
|
||||
</Flex>
|
||||
<div className="flex flex-col gap-3">
|
||||
{form.watch("revealType") === "delayed" && (
|
||||
<>
|
||||
<Heading size="title.sm">Let's set a password</Heading>
|
||||
<Alert status="warning" borderRadius="lg">
|
||||
<Alert borderRadius="lg" status="warning">
|
||||
<AlertIcon />
|
||||
You'll need this password to reveal your NFTs. Please save it
|
||||
somewhere safe.
|
||||
@@ -350,10 +346,10 @@ const SelectReveal: React.FC<SelectRevealProps> = ({
|
||||
gap={{ base: 4, md: 0 }}
|
||||
>
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
!!form.getFieldState("password", form.formState).error
|
||||
}
|
||||
isRequired
|
||||
mr={4}
|
||||
>
|
||||
<FormLabel>Password</FormLabel>
|
||||
@@ -366,13 +362,13 @@ const SelectReveal: React.FC<SelectRevealProps> = ({
|
||||
<InputRightElement cursor="pointer">
|
||||
{show ? (
|
||||
<EyeIcon
|
||||
onClick={() => setShow(!show)}
|
||||
className="size-3"
|
||||
onClick={() => setShow(!show)}
|
||||
/>
|
||||
) : (
|
||||
<EyeOffIcon
|
||||
onClick={() => setShow(!show)}
|
||||
className="size-3"
|
||||
onClick={() => setShow(!show)}
|
||||
/>
|
||||
)}
|
||||
</InputRightElement>
|
||||
@@ -386,10 +382,10 @@ const SelectReveal: React.FC<SelectRevealProps> = ({
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
!!form.getFieldState("confirmPassword", form.formState).error
|
||||
}
|
||||
isRequired
|
||||
>
|
||||
<FormLabel>Confirm password</FormLabel>
|
||||
<Input
|
||||
@@ -416,14 +412,14 @@ const SelectReveal: React.FC<SelectRevealProps> = ({
|
||||
<FormLabel>Image</FormLabel>
|
||||
<div className="w-auto md:w-[350px]">
|
||||
<FileInput
|
||||
client={client}
|
||||
accept={{ "image/*": [] }}
|
||||
value={imageUrl}
|
||||
showUploadButton
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
client={client}
|
||||
setValue={(file) =>
|
||||
form.setValue("placeHolder.image", file)
|
||||
}
|
||||
className="rounded border border-border transition-all duration-200"
|
||||
showUploadButton
|
||||
value={imageUrl}
|
||||
/>
|
||||
</div>
|
||||
<FormHelperText>
|
||||
@@ -437,10 +433,10 @@ const SelectReveal: React.FC<SelectRevealProps> = ({
|
||||
</FormErrorMessage>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
isRequired
|
||||
isInvalid={
|
||||
!!form.getFieldState("placeHolder.name", form.formState).error
|
||||
}
|
||||
isRequired
|
||||
>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
/** biome-ignore-all lint/nursery/noNestedComponentDefinitions: FIXME */
|
||||
|
||||
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/file-preview";
|
||||
import {
|
||||
Flex,
|
||||
IconButton,
|
||||
@@ -23,7 +24,8 @@ import { useMemo } from "react";
|
||||
import { type Column, usePagination, useTable } from "react-table";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import type { NFTInput } from "thirdweb/utils";
|
||||
import { FilePreview } from "../../app/(app)/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/file-preview";
|
||||
import { CodeClient } from "@/components/ui/code/code.client";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
|
||||
interface BatchTableProps {
|
||||
data: NFTInput[];
|
||||
@@ -42,37 +44,36 @@ export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
let cols: Column<NFTInput>[] = [];
|
||||
if (nextTokenIdToMint !== undefined) {
|
||||
cols = cols.concat({
|
||||
Header: "Token ID",
|
||||
accessor: (_row, index) => String(nextTokenIdToMint + BigInt(index)),
|
||||
Header: "Token ID",
|
||||
});
|
||||
}
|
||||
|
||||
cols = cols.concat([
|
||||
{
|
||||
Header: "Image",
|
||||
accessor: (row) => row.image,
|
||||
Cell: ({ cell: { value } }: { cell: { value?: string } }) => (
|
||||
<FilePreview
|
||||
className="size-24 shrink-0 rounded-lg object-contain"
|
||||
srcOrFile={value}
|
||||
client={client}
|
||||
srcOrFile={value}
|
||||
/>
|
||||
),
|
||||
Header: "Image",
|
||||
},
|
||||
{
|
||||
Header: "Animation Url",
|
||||
accessor: (row) => row.animation_url,
|
||||
Cell: ({ cell: { value } }: { cell: { value?: string } }) => (
|
||||
<FilePreview
|
||||
className="size-24 shrink-0 rounded-lg"
|
||||
srcOrFile={value}
|
||||
client={client}
|
||||
srcOrFile={value}
|
||||
/>
|
||||
),
|
||||
Header: "Animation Url",
|
||||
},
|
||||
{ Header: "Name", accessor: (row) => row.name },
|
||||
{ accessor: (row) => row.name, Header: "Name" },
|
||||
{
|
||||
Header: "Description",
|
||||
accessor: (row) => (
|
||||
<ToolTipLabel label={row.description}>
|
||||
<p className="line-clamp-6 whitespace-pre-wrap">
|
||||
@@ -80,9 +81,9 @@ export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
</p>
|
||||
</ToolTipLabel>
|
||||
),
|
||||
Header: "Description",
|
||||
},
|
||||
{
|
||||
Header: "Attributes",
|
||||
accessor: (row) => row.attributes || row.properties,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
Cell: ({ cell }: { cell: any }) =>
|
||||
@@ -93,9 +94,10 @@ export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
scrollableClassName="max-w-[300px]"
|
||||
/>
|
||||
) : null,
|
||||
Header: "Attributes",
|
||||
},
|
||||
{ Header: "External URL", accessor: (row) => row.external_url },
|
||||
{ Header: "Background Color", accessor: (row) => row.background_color },
|
||||
{ accessor: (row) => row.external_url, Header: "External URL" },
|
||||
{ accessor: (row) => row.background_color, Header: "Background Color" },
|
||||
]);
|
||||
return cols;
|
||||
}, [nextTokenIdToMint, client]);
|
||||
@@ -124,8 +126,8 @@ export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
columns,
|
||||
data,
|
||||
initialState: {
|
||||
pageSize: 50,
|
||||
pageIndex: 0,
|
||||
pageSize: 50,
|
||||
},
|
||||
},
|
||||
// will be fixed with @tanstack/react-table v8
|
||||
@@ -136,7 +138,7 @@ export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
// Render the UI for your table
|
||||
return (
|
||||
<Flex flexGrow={1} overflow="auto">
|
||||
<TableContainer maxW="100%" className="w-full">
|
||||
<TableContainer className="w-full" maxW="100%">
|
||||
<Table {...getTableProps()}>
|
||||
<Thead>
|
||||
{headerGroups.map((headerGroup, index) => (
|
||||
@@ -159,8 +161,8 @@ export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
return (
|
||||
<Tr
|
||||
{...row.getRowProps()}
|
||||
borderBottomWidth={1}
|
||||
_last={{ borderBottomWidth: 0 }}
|
||||
borderBottomWidth={1}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: FIXME
|
||||
key={rowIndex}
|
||||
>
|
||||
@@ -187,15 +189,15 @@ export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="first page"
|
||||
icon={<ChevronFirstIcon className="size-4" />}
|
||||
isDisabled={!canPreviousPage}
|
||||
onClick={() => gotoPage(0)}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canPreviousPage}
|
||||
aria-label="previous page"
|
||||
icon={<ChevronLeftIcon className="size-4" />}
|
||||
isDisabled={!canPreviousPage}
|
||||
onClick={() => previousPage()}
|
||||
/>
|
||||
<p className="whitespace-nowrap">
|
||||
@@ -203,15 +205,15 @@ export const BatchTable: React.FC<BatchTableProps> = ({
|
||||
<strong>{pageOptions.length}</strong>
|
||||
</p>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="next page"
|
||||
icon={<ChevronRightIcon className="size-4" />}
|
||||
isDisabled={!canNextPage}
|
||||
onClick={() => nextPage()}
|
||||
/>
|
||||
<IconButton
|
||||
isDisabled={!canNextPage}
|
||||
aria-label="last page"
|
||||
icon={<ChevronLastIcon className="size-4" />}
|
||||
isDisabled={!canNextPage}
|
||||
onClick={() => gotoPage(pageCount - 1)}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import type { JSX, MouseEventHandler } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import type { JSX, MouseEventHandler } from "react";
|
||||
|
||||
interface SelectOptionProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
name: string;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UnorderedList } from "@/components/ui/List/List";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UploadIcon } from "lucide-react";
|
||||
import { InlineCode } from "@/components/ui/inline-code";
|
||||
import { UnorderedList } from "@/components/ui/List/List";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UploadStepProps {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: FIXME
|
||||
@@ -61,16 +61,16 @@ export const UploadStep: React.FC<UploadStepProps> = ({
|
||||
<li>
|
||||
Files <em>must</em> contain one .csv or .json file with metadata. -{" "}
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example.csv"
|
||||
>
|
||||
Download example.csv
|
||||
</a>
|
||||
.{" "}
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example.json"
|
||||
>
|
||||
Download example.json
|
||||
@@ -111,8 +111,8 @@ export const UploadStep: React.FC<UploadStepProps> = ({
|
||||
<InlineCode code="animation_url" /> column and add the IPFS hashes
|
||||
there.{" "}
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example-with-ipfs.csv"
|
||||
>
|
||||
Download example.csv
|
||||
@@ -123,8 +123,8 @@ export const UploadStep: React.FC<UploadStepProps> = ({
|
||||
the name of your files to the <InlineCode code="image" /> and
|
||||
<InlineCode code="animation_url" /> column.{" "}
|
||||
<a
|
||||
download
|
||||
className="text-link-foreground hover:text-foreground"
|
||||
download
|
||||
href="/assets/examples/example-with-maps.csv"
|
||||
>
|
||||
Download example.csv
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client";
|
||||
import { CircleXIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -9,16 +13,12 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { PRO_CONTACT_US_URL } from "@/constants/pro";
|
||||
import { useStripeRedirectEvent } from "@/hooks/stripe/redirect-event";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { CircleXIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useStripeRedirectEvent } from "../../../../../app/(app)/(stripe)/stripe-redirect/stripeRedirectChannel";
|
||||
import { buildCancelPlanUrl } from "../../../../../app/(app)/(stripe)/utils/build-url";
|
||||
import { PRO_CONTACT_US_URL } from "../../../../../constants/pro";
|
||||
import { pollWithTimeout } from "../../../../../utils/pollWithTimeout";
|
||||
import { tryCatch } from "../../../../../utils/try-catch";
|
||||
import { pollWithTimeout } from "@/utils/pollWithTimeout";
|
||||
import { buildCancelPlanUrl } from "@/utils/stripe/build-url";
|
||||
import { tryCatch } from "@/utils/try-catch";
|
||||
|
||||
export function CancelPlanButton(props: {
|
||||
teamId: string;
|
||||
@@ -32,9 +32,9 @@ export function CancelPlanButton(props: {
|
||||
if (props.billingStatus !== "invalidPayment" && props.currentPlan !== "pro") {
|
||||
return (
|
||||
<ImmediateCancelPlanButton
|
||||
teamId={props.teamId}
|
||||
getTeam={props.getTeam}
|
||||
disabled={props.disabled}
|
||||
getTeam={props.getTeam}
|
||||
teamId={props.teamId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -43,10 +43,10 @@ export function CancelPlanButton(props: {
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 bg-background"
|
||||
disabled={props.disabled}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<CircleXIcon className="size-4 text-muted-foreground" />
|
||||
Cancel Plan
|
||||
@@ -80,9 +80,10 @@ function UnpaidInvoicesWarning({ teamSlug }: { teamSlug: string }) {
|
||||
You have unpaid invoices. Please pay them before cancelling your plan.
|
||||
</p>
|
||||
|
||||
<Button variant="outline" asChild className="w-full gap-2 bg-card">
|
||||
<Button asChild className="w-full gap-2 bg-card" variant="outline">
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
See Invoices
|
||||
@@ -102,7 +103,7 @@ function ProPlanCancelPlanSheetContent() {
|
||||
Please contact us to cancel your Pro plan
|
||||
</p>
|
||||
|
||||
<Button variant="outline" asChild className="w-full gap-2 bg-card">
|
||||
<Button asChild className="w-full gap-2 bg-card" variant="outline">
|
||||
<Link href={PRO_CONTACT_US_URL} target="_blank">
|
||||
Contact Us <ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
@@ -149,11 +150,11 @@ function ImmediateCancelPlanButton(props: {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="gap-2 bg-background"
|
||||
disabled={showPlanSpinner || props.disabled}
|
||||
asChild
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Link href={buildCancelPlanUrl({ teamId: props.teamId })} target="_blank">
|
||||
{showPlanSpinner ? (
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Account, BillingCredit } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ChainIconClient } from "components/icons/ChainIcon";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import { useLocalStorage } from "hooks/useLocalStorage";
|
||||
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 { formatToDollars } from "./formatToDollars";
|
||||
|
||||
interface CreditsItemProps {
|
||||
@@ -28,8 +27,6 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
client,
|
||||
teamSlug,
|
||||
}) => {
|
||||
const trackEvent = useTrack();
|
||||
|
||||
const [hasAppliedForOpGrant] = useLocalStorage(
|
||||
`appliedForOpGrant-${twAccount.id}`,
|
||||
false,
|
||||
@@ -55,23 +52,23 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
<div className="absolute top-0 right-0">
|
||||
{isOpCredit ? (
|
||||
<ChainIconClient
|
||||
src="ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/optimism/512.png"
|
||||
className="size-6"
|
||||
client={client}
|
||||
src="ipfs://QmcxZHpyJa8T4i63xqjPYrZ6tKrt55tZJpbXcjSDKuKaf9/optimism/512.png"
|
||||
/>
|
||||
) : isTwCredit ? (
|
||||
<Image
|
||||
src={require("../../../../../public/brand/thirdweb-icon.svg")}
|
||||
alt="tw-credit"
|
||||
className="size-6"
|
||||
objectFit="contain"
|
||||
src={require("../../../../public/brand/thirdweb-icon.svg")}
|
||||
/>
|
||||
) : isStartupCredit ? (
|
||||
<Image
|
||||
src={require("../../../../../public/brand/thirdweb-icon.svg")}
|
||||
alt="tw-credit"
|
||||
className="size-6"
|
||||
objectFit="contain"
|
||||
src={require("../../../../public/brand/thirdweb-icon.svg")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -134,11 +131,6 @@ export const CreditsItem: React.FC<CreditsItemProps> = ({
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/credits`}
|
||||
onClick={() => {
|
||||
trackEvent({
|
||||
category: "op-sponsorship",
|
||||
action: "click",
|
||||
label: "apply-now",
|
||||
});
|
||||
if (onClickApply) {
|
||||
onClickApply();
|
||||
}
|
||||
@@ -2,11 +2,8 @@
|
||||
|
||||
// TODO - convert to RSC
|
||||
|
||||
import {
|
||||
type Account,
|
||||
useAccountCredits,
|
||||
} from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { type Account, useAccountCredits } from "@/hooks/useApi";
|
||||
import { CreditsItem } from "./CreditsItem";
|
||||
|
||||
export const CreditsInfoCard = (props: {
|
||||
@@ -26,19 +23,19 @@ export const CreditsInfoCard = (props: {
|
||||
return (
|
||||
<section className="flex flex-col gap-4">
|
||||
<CreditsItem
|
||||
client={props.client}
|
||||
credit={opCredit}
|
||||
isOpCreditDefault={true}
|
||||
twAccount={props.twAccount}
|
||||
client={props.client}
|
||||
teamSlug={props.teamSlug}
|
||||
twAccount={props.twAccount}
|
||||
/>
|
||||
{restCredits?.map((credit) => (
|
||||
<CreditsItem
|
||||
key={credit.couponId}
|
||||
credit={credit}
|
||||
twAccount={props.twAccount}
|
||||
client={props.client}
|
||||
credit={credit}
|
||||
key={credit.couponId}
|
||||
teamSlug={props.teamSlug}
|
||||
twAccount={props.twAccount}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
@@ -1,24 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
import { PricingCard } from "@/components/blocks/pricing-card";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { PRO_CONTACT_US_URL } from "@/constants/pro";
|
||||
import { useStripeRedirectEvent } from "@/hooks/stripe/redirect-event";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useTransition } from "react";
|
||||
|
||||
import { useStripeRedirectEvent } from "../../../../app/(app)/(stripe)/stripe-redirect/stripeRedirectChannel";
|
||||
import { getValidTeamPlan } from "../../../../app/(app)/team/components/TeamHeader/getValidTeamPlan";
|
||||
import { PRO_CONTACT_US_URL } from "../../../../constants/pro";
|
||||
import { getValidTeamPlan } from "@/utils/getValidTeamPlan";
|
||||
|
||||
// this is used to determine whether to show "Upgrade" or "Downgrade" label based on tier level
|
||||
const planToTierRecord: Record<Team["billingPlan"], number> = {
|
||||
free: 0,
|
||||
starter: 2,
|
||||
growth_legacy: 3,
|
||||
growth: 4,
|
||||
accelerate: 5,
|
||||
scale: 6,
|
||||
free: 0,
|
||||
growth: 4,
|
||||
growth_legacy: 3,
|
||||
pro: 7,
|
||||
scale: 6,
|
||||
starter: 2,
|
||||
};
|
||||
|
||||
interface BillingPricingProps {
|
||||
@@ -100,63 +99,63 @@ export const BillingPricing: React.FC<BillingPricingProps> = ({
|
||||
<PricingCard
|
||||
billingPlan="starter"
|
||||
billingStatus={team.billingStatus}
|
||||
current={validTeamPlan === "starter"}
|
||||
cta={getPlanCta(
|
||||
validTeamPlan,
|
||||
"starter",
|
||||
isCurrentPlanScheduledToCancel,
|
||||
)}
|
||||
teamSlug={team.slug}
|
||||
teamId={team.id}
|
||||
highlighted={highlightStarterPlan}
|
||||
current={validTeamPlan === "starter"}
|
||||
getTeam={getTeam}
|
||||
highlighted={highlightStarterPlan}
|
||||
teamId={team.id}
|
||||
teamSlug={team.slug}
|
||||
/>
|
||||
|
||||
{/* Growth */}
|
||||
<PricingCard
|
||||
billingPlan="growth"
|
||||
billingStatus={team.billingStatus}
|
||||
activeTrialEndsAt={
|
||||
validTeamPlan === "growth" ? trialPeriodEndedAt : undefined
|
||||
}
|
||||
current={validTeamPlan === "growth"}
|
||||
billingPlan="growth"
|
||||
billingStatus={team.billingStatus}
|
||||
cta={getPlanCta(
|
||||
validTeamPlan,
|
||||
"growth",
|
||||
isCurrentPlanScheduledToCancel,
|
||||
)}
|
||||
highlighted={highlightGrowthPlan}
|
||||
teamSlug={team.slug}
|
||||
teamId={team.id}
|
||||
current={validTeamPlan === "growth"}
|
||||
getTeam={getTeam}
|
||||
highlighted={highlightGrowthPlan}
|
||||
teamId={team.id}
|
||||
teamSlug={team.slug}
|
||||
/>
|
||||
|
||||
{/* Scale */}
|
||||
<PricingCard
|
||||
billingPlan="scale"
|
||||
billingStatus={team.billingStatus}
|
||||
teamSlug={team.slug}
|
||||
teamId={team.id}
|
||||
current={validTeamPlan === "scale"}
|
||||
cta={getPlanCta(
|
||||
validTeamPlan,
|
||||
"scale",
|
||||
isCurrentPlanScheduledToCancel,
|
||||
)}
|
||||
highlighted={highlightScalePlan}
|
||||
current={validTeamPlan === "scale"}
|
||||
getTeam={getTeam}
|
||||
highlighted={highlightScalePlan}
|
||||
teamId={team.id}
|
||||
teamSlug={team.slug}
|
||||
/>
|
||||
|
||||
{/* Pro */}
|
||||
<PricingCard
|
||||
billingPlan="pro"
|
||||
billingStatus={team.billingStatus}
|
||||
teamSlug={team.slug}
|
||||
teamId={team.id}
|
||||
current={validTeamPlan === "pro"}
|
||||
cta={getPlanCta(validTeamPlan, "pro", isCurrentPlanScheduledToCancel)}
|
||||
highlighted={highlightProPlan}
|
||||
current={validTeamPlan === "pro"}
|
||||
getTeam={getTeam}
|
||||
highlighted={highlightProPlan}
|
||||
teamId={team.id}
|
||||
teamSlug={team.slug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,8 +170,8 @@ function getPlanCta(
|
||||
// if the CURRENT plan is pro, show contact us link
|
||||
if (currentPlan === "pro") {
|
||||
return {
|
||||
label: "Contact us",
|
||||
href: PRO_CONTACT_US_URL,
|
||||
label: "Contact us",
|
||||
type: "link",
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useMemo, useState } from "react";
|
||||
import { type CouponData, CouponsUI } from "./CouponsUI";
|
||||
|
||||
const meta = {
|
||||
title: "Billing/Coupons",
|
||||
component: Story,
|
||||
title: "Billing/Coupons",
|
||||
} satisfies Meta<typeof CouponsUI>;
|
||||
|
||||
export default meta;
|
||||
@@ -16,54 +16,54 @@ type Story = StoryObj<typeof CouponsUI>;
|
||||
const couponStubs: CouponData[] = [
|
||||
// Forever
|
||||
{
|
||||
id: "di_1QAE7jCQUO4TBFqFQgnS4Qj4",
|
||||
start: 1729011691,
|
||||
end: null,
|
||||
coupon: {
|
||||
id: "forever_active",
|
||||
name: "Example 1",
|
||||
duration: "forever",
|
||||
duration_in_months: null,
|
||||
id: "forever_active",
|
||||
name: "Example 1",
|
||||
},
|
||||
end: null,
|
||||
id: "di_1QAE7jCQUO4TBFqFQgnS4Qj4",
|
||||
start: 1729011691,
|
||||
},
|
||||
|
||||
// Once
|
||||
{
|
||||
id: "once",
|
||||
start: 1733391052,
|
||||
end: null,
|
||||
coupon: {
|
||||
id: "once_active",
|
||||
name: "Example 2",
|
||||
duration: "once",
|
||||
duration_in_months: null,
|
||||
id: "once_active",
|
||||
name: "Example 2",
|
||||
},
|
||||
end: null,
|
||||
id: "once",
|
||||
start: 1733391052,
|
||||
},
|
||||
|
||||
// Repeating - 3 months
|
||||
{
|
||||
id: "repeating-3",
|
||||
start: 1733391629,
|
||||
end: 1743391629, // Will end in the future
|
||||
coupon: {
|
||||
id: "repeating_active",
|
||||
name: "Example 3",
|
||||
duration: "repeating",
|
||||
duration_in_months: 3,
|
||||
id: "repeating_active",
|
||||
name: "Example 3",
|
||||
},
|
||||
end: 1743391629,
|
||||
id: "repeating-3", // Will end in the future
|
||||
start: 1733391629,
|
||||
},
|
||||
|
||||
// Repeating - 1 month
|
||||
{
|
||||
id: "repeating-1",
|
||||
start: 1733391713,
|
||||
end: 1736391713,
|
||||
coupon: {
|
||||
id: "repeating_single",
|
||||
name: "Example 4",
|
||||
duration: "repeating",
|
||||
duration_in_months: 1,
|
||||
id: "repeating_single",
|
||||
name: "Example 4",
|
||||
},
|
||||
end: 1736391713,
|
||||
id: "repeating-1",
|
||||
start: 1733391713,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -102,6 +102,9 @@ function Story() {
|
||||
200 | 400 | 401 | 409 | 429 | 500
|
||||
>(200);
|
||||
|
||||
const hasAccountCouponId = useId();
|
||||
const paymentSetupId = useId();
|
||||
|
||||
return (
|
||||
<div className="container max-w-[1100px] py-10">
|
||||
<div className="mb-8 space-y-6">
|
||||
@@ -109,22 +112,22 @@ function Story() {
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">Status</h3>
|
||||
<RadioGroup
|
||||
value={status}
|
||||
className="flex gap-4"
|
||||
onValueChange={(value) =>
|
||||
setStatus(value as "pending" | "error" | "success")
|
||||
}
|
||||
className="flex gap-4"
|
||||
value={status}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="pending" id="status-pending" />
|
||||
<RadioGroupItem value="pending" />
|
||||
<Label htmlFor="status-pending">Pending</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="error" id="status-error" />
|
||||
<RadioGroupItem value="error" />
|
||||
<Label htmlFor="status-error">Error</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="success" id="status-success" />
|
||||
<RadioGroupItem value="success" />
|
||||
<Label htmlFor="status-success">Success</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
@@ -133,22 +136,22 @@ function Story() {
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">Active Coupons</h3>
|
||||
<RadioGroup
|
||||
value={activeCouponsType}
|
||||
className="flex gap-4"
|
||||
onValueChange={(value) =>
|
||||
setActiveCouponsType(value as "multiple" | "zero" | "one")
|
||||
}
|
||||
className="flex gap-4"
|
||||
value={activeCouponsType}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="multiple" id="coupons-multiple" />
|
||||
<RadioGroupItem value="multiple" />
|
||||
<Label htmlFor="coupons-multiple">Multiple</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="zero" id="coupons-zero" />
|
||||
<RadioGroupItem value="zero" />
|
||||
<Label htmlFor="coupons-zero">Zero</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="one" id="coupons-one" />
|
||||
<RadioGroupItem value="one" />
|
||||
<Label htmlFor="coupons-one">One</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
@@ -157,19 +160,19 @@ function Story() {
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-semibold text-lg">Apply Coupon Status</h3>
|
||||
<RadioGroup
|
||||
value={applyCouponStatus.toString()}
|
||||
className="flex gap-4"
|
||||
onValueChange={(value) =>
|
||||
setApplyCouponStatus(
|
||||
Number.parseInt(value) as 200 | 400 | 401 | 409 | 429 | 500,
|
||||
)
|
||||
}
|
||||
value={applyCouponStatus.toString()}
|
||||
>
|
||||
{[200, 400, 401, 409, 429, 500].map((code) => (
|
||||
<div key={code} className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2" key={code}>
|
||||
<RadioGroupItem
|
||||
value={code.toString()}
|
||||
id={`status-${code}`}
|
||||
value={code.toString()}
|
||||
/>
|
||||
<Label htmlFor={`status-${code}`}>{code}</Label>
|
||||
</div>
|
||||
@@ -183,52 +186,52 @@ function Story() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
id="has-account-coupon"
|
||||
checked={hasAccountCoupon}
|
||||
id={hasAccountCouponId}
|
||||
onCheckedChange={setHasAccountCoupon}
|
||||
/>
|
||||
<Label htmlFor="has-account-coupon">Has Account Coupon</Label>
|
||||
<Label htmlFor={hasAccountCouponId}>Has Account Coupon</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Switch
|
||||
id="payment-setup"
|
||||
checked={isPaymentSetup}
|
||||
id={paymentSetupId}
|
||||
onCheckedChange={setIsPaymentSetup}
|
||||
/>
|
||||
<Label htmlFor="payment-setup">Payment Setup</Label>
|
||||
<Label htmlFor={paymentSetupId}>Payment Setup</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CouponsUI
|
||||
status={status}
|
||||
activeCoupons={activeCoupons}
|
||||
accountCouponId={hasAccountCoupon ? activeCoupons[0]?.id : undefined}
|
||||
isPaymentSetup={isPaymentSetup}
|
||||
activeCoupons={activeCoupons}
|
||||
applyCoupon={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return {
|
||||
status: applyCouponStatus,
|
||||
data:
|
||||
applyCouponStatus === 200
|
||||
? {
|
||||
id: "xyz",
|
||||
start: 1727992716,
|
||||
end: 1759528716,
|
||||
coupon: {
|
||||
id: "test",
|
||||
name: "TEST COUPON",
|
||||
duration: "repeating",
|
||||
duration_in_months: 12,
|
||||
id: "test",
|
||||
name: "TEST COUPON",
|
||||
},
|
||||
end: 1759528716,
|
||||
id: "xyz",
|
||||
start: 1727992716,
|
||||
}
|
||||
: null,
|
||||
status: applyCouponStatus,
|
||||
};
|
||||
}}
|
||||
deleteCoupon={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}}
|
||||
isPaymentSetup={isPaymentSetup}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { Suspense } from "react";
|
||||
import { getAuthToken } from "../../../../../app/(app)/api/lib/getAuthToken";
|
||||
import { getAuthToken } from "@/api/auth-token";
|
||||
import { NEXT_PUBLIC_THIRDWEB_API_HOST } from "@/constants/public-envs";
|
||||
import { CouponsClient } from "./CouponsClient";
|
||||
import type { CouponData } from "./CouponsUI";
|
||||
|
||||
@@ -39,10 +39,10 @@ async function AsyncCoupons(props: {
|
||||
) {
|
||||
return (
|
||||
<CouponsClient
|
||||
status="error"
|
||||
activeCoupons={[]}
|
||||
accountCouponId={undefined}
|
||||
activeCoupons={[]}
|
||||
isPaymentSetup={props.isPaymentSetup}
|
||||
status="error"
|
||||
teamId={props.teamId}
|
||||
/>
|
||||
);
|
||||
@@ -58,34 +58,31 @@ async function AsyncCoupons(props: {
|
||||
|
||||
return (
|
||||
<CouponsClient
|
||||
status="success"
|
||||
activeCoupons={allCouponsData.data}
|
||||
accountCouponId={accountCouponData.data?.id}
|
||||
activeCoupons={allCouponsData.data}
|
||||
isPaymentSetup={props.isPaymentSetup}
|
||||
status="success"
|
||||
teamId={props.teamId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Coupons(props: {
|
||||
teamId: string;
|
||||
isPaymentSetup: boolean;
|
||||
}) {
|
||||
export function Coupons(props: { teamId: string; isPaymentSetup: boolean }) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<CouponsClient
|
||||
status="pending"
|
||||
activeCoupons={[]}
|
||||
accountCouponId={undefined}
|
||||
activeCoupons={[]}
|
||||
isPaymentSetup={true}
|
||||
status="pending"
|
||||
teamId={props.teamId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<AsyncCoupons
|
||||
teamId={props.teamId}
|
||||
isPaymentSetup={props.isPaymentSetup}
|
||||
teamId={props.teamId}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -16,6 +16,35 @@ export function CouponsClient(
|
||||
return (
|
||||
<CouponsUI
|
||||
{...props}
|
||||
applyCoupon={async (promoCode: string) => {
|
||||
const res = await apiServerProxy<{ data: CouponData }>({
|
||||
body: JSON.stringify({
|
||||
promoCode,
|
||||
teamId: props.teamId,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
pathname: "/v1/coupons/redeem",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
data: null,
|
||||
status: res.status,
|
||||
};
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
router.refresh();
|
||||
|
||||
return {
|
||||
data: json.data,
|
||||
status: 200,
|
||||
};
|
||||
}}
|
||||
deleteCoupon={async () => {
|
||||
const res = await apiServerProxy({
|
||||
method: "DELETE",
|
||||
@@ -29,35 +58,6 @@ export function CouponsClient(
|
||||
|
||||
router.refresh();
|
||||
}}
|
||||
applyCoupon={async (promoCode: string) => {
|
||||
const res = await apiServerProxy<{ data: CouponData }>({
|
||||
method: "POST",
|
||||
pathname: "/v1/coupons/redeem",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
promoCode,
|
||||
teamId: props.teamId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
status: res.status,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const json = res.data;
|
||||
|
||||
router.refresh();
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
data: json.data,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { format, fromUnixTime } from "date-fns";
|
||||
import {
|
||||
@@ -22,6 +10,18 @@ import {
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
|
||||
type Coupon = {
|
||||
id: string;
|
||||
@@ -211,17 +211,17 @@ function DeleteCouponButton(props: {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Remove coupon"
|
||||
disabled={props.disabled}
|
||||
onClick={() => {
|
||||
const promise = deleteCoupon.mutateAsync();
|
||||
toast.promise(promise, {
|
||||
success: "Coupon removed successfully",
|
||||
error: "Failed to remove coupon",
|
||||
success: "Coupon removed successfully",
|
||||
});
|
||||
}}
|
||||
aria-label="Remove coupon"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{deleteCoupon.isPending ? (
|
||||
<Spinner className="size-4" />
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
import { AlertTriangleIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProductSKU } from "@/types/billing";
|
||||
import {
|
||||
buildBillingPortalUrl,
|
||||
buildCheckoutUrl,
|
||||
} from "../../app/(app)/(stripe)/utils/build-url";
|
||||
import type { Team } from "../api/team";
|
||||
import type { ProductSKU } from "../lib/billing";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button, type ButtonProps } from "./ui/button";
|
||||
} from "@/utils/stripe/build-url";
|
||||
|
||||
export function CheckoutButton(props: {
|
||||
buttonProps?: Omit<ButtonProps, "children">;
|
||||
@@ -38,11 +38,12 @@ export function CheckoutButton(props: {
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
target="_blank"
|
||||
href={buildCheckoutUrl({
|
||||
teamSlug: props.teamSlug,
|
||||
sku: props.sku,
|
||||
teamSlug: props.teamSlug,
|
||||
})}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{props.children}
|
||||
</Link>
|
||||
@@ -58,8 +59,8 @@ function BillingWarning({ teamSlug }: { teamSlug: string }) {
|
||||
<p className="text-sm">
|
||||
You have outstanding invoices. Please{" "}
|
||||
<Link
|
||||
href={`/team/${teamSlug}/~/settings/invoices?status=open`}
|
||||
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`}
|
||||
>
|
||||
pay them
|
||||
</Link>{" "}
|
||||
@@ -77,17 +78,17 @@ export function BillingPortalButton(props: {
|
||||
return (
|
||||
<Button
|
||||
{...props.buttonProps}
|
||||
asChild
|
||||
className={cn(props.buttonProps?.className, "gap-2")}
|
||||
disabled={props.buttonProps?.disabled}
|
||||
asChild
|
||||
onClick={async (e) => {
|
||||
props.buttonProps?.onClick?.(e);
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href={buildBillingPortalUrl({ teamSlug: props.teamSlug })}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{props.children}
|
||||
</a>
|
||||
@@ -1,7 +1,7 @@
|
||||
export const formatToDollars = (cents: number) => {
|
||||
const dollars = cents / 100;
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(dollars);
|
||||
};
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { reSubscribePlan } from "@/actions/billing";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import { useState, useTransition } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { pollWithTimeout } from "../../../../../utils/pollWithTimeout";
|
||||
import { tryCatch } from "../../../../../utils/try-catch";
|
||||
import { reSubscribePlan } from "@/actions/billing";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { pollWithTimeout } from "../../../utils/pollWithTimeout";
|
||||
import { tryCatch } from "../../../utils/try-catch";
|
||||
|
||||
export function RenewSubscriptionButton(props: {
|
||||
teamId: string;
|
||||
@@ -68,11 +68,11 @@ export function RenewSubscriptionButton(props: {
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleRenewSubscription}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
disabled={showSpinner || props.disabled}
|
||||
onClick={handleRenewSubscription}
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
{showSpinner ? (
|
||||
<Spinner className="size-4" />
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { PercentIcon } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type InputProps = React.ComponentProps<typeof Input>;
|
||||
|
||||
@@ -51,7 +52,7 @@ export const BasisPointsInput: React.FC<BasisPointsInputProps> = ({
|
||||
"rounded-r-none border-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
restInputProps.className,
|
||||
)}
|
||||
onChange={(e) => setStringValue(e.target.value)}
|
||||
maxLength={5}
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value;
|
||||
const validValue = val.match(
|
||||
@@ -63,7 +64,7 @@ export const BasisPointsInput: React.FC<BasisPointsInputProps> = ({
|
||||
setStringValue("0.00");
|
||||
}
|
||||
}}
|
||||
maxLength={5}
|
||||
onChange={(e) => setStringValue(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center justify-center border-border border-l bg-card px-3 font-medium">
|
||||
<PercentIcon className="size-4" />
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { isAddress, NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS } from "thirdweb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -7,11 +9,9 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { CURRENCIES, type CurrencyMetadata } from "@/constants/currencies";
|
||||
import { useAllChainsData } from "@/hooks/chains/allChains";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CURRENCIES, type CurrencyMetadata } from "constants/currencies";
|
||||
import { useAllChainsData } from "hooks/chains/allChains";
|
||||
import { useMemo, useState } from "react";
|
||||
import { NATIVE_TOKEN_ADDRESS, ZERO_ADDRESS, isAddress } from "thirdweb";
|
||||
|
||||
interface CurrencySelectorProps {
|
||||
value: string;
|
||||
@@ -113,15 +113,15 @@ export function CurrencySelector({
|
||||
</Button>
|
||||
<Input
|
||||
className="w-full rounded-none"
|
||||
required
|
||||
placeholder="ERC20 Address"
|
||||
value={editCustomCurrency}
|
||||
onChange={(e) => setEditCustomCurrency(e.target.value)}
|
||||
placeholder="ERC20 Address"
|
||||
required
|
||||
value={editCustomCurrency}
|
||||
/>
|
||||
<Button
|
||||
className="rounded-r-md rounded-l-none"
|
||||
onClick={addCustomCurrency}
|
||||
disabled={!isAddress(editCustomCurrency)}
|
||||
onClick={addCustomCurrency}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
@@ -139,13 +139,6 @@ export function CurrencySelector({
|
||||
>
|
||||
<Select
|
||||
disabled={isDisabled}
|
||||
value={
|
||||
isPaymentsSelector
|
||||
? value
|
||||
: value?.toLowerCase() === ZERO_ADDRESS.toLowerCase()
|
||||
? NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
: value?.toLowerCase()
|
||||
}
|
||||
onValueChange={(val) => {
|
||||
if (val === "custom") {
|
||||
setIsAddingCurrency(true);
|
||||
@@ -155,6 +148,13 @@ export function CurrencySelector({
|
||||
} as React.ChangeEvent<HTMLSelectElement>);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
isPaymentsSelector
|
||||
? value
|
||||
: value?.toLowerCase() === ZERO_ADDRESS.toLowerCase()
|
||||
? NATIVE_TOKEN_ADDRESS.toLowerCase()
|
||||
: value?.toLowerCase()
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select Currency" />
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { BadgeContainer } from "@/storybook/utils";
|
||||
import { DangerSettingCard } from "./DangerSettingCard";
|
||||
|
||||
const meta = {
|
||||
title: "blocks/Cards/DangerSettingCard",
|
||||
component: Story,
|
||||
parameters: {
|
||||
nextjs: {
|
||||
appDirectory: true,
|
||||
},
|
||||
},
|
||||
title: "blocks/Cards/DangerSettingCard",
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
@@ -24,29 +24,29 @@ function Story() {
|
||||
<div className="container flex max-w-[1000px] flex-col gap-8 py-10">
|
||||
<BadgeContainer label="Base">
|
||||
<DangerSettingCard
|
||||
title="This is a title"
|
||||
description="This is a description"
|
||||
buttonLabel="Some Action"
|
||||
buttonOnClick={() => {}}
|
||||
isPending={false}
|
||||
confirmationDialog={{
|
||||
title: "This is confirmation title",
|
||||
description: "This is confirmation description",
|
||||
title: "This is confirmation title",
|
||||
}}
|
||||
description="This is a description"
|
||||
isPending={false}
|
||||
title="This is a title"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Loading">
|
||||
<DangerSettingCard
|
||||
title="This is a title"
|
||||
description="This is a description"
|
||||
buttonLabel="Some Action"
|
||||
buttonOnClick={() => {}}
|
||||
isPending={true}
|
||||
confirmationDialog={{
|
||||
title: "This is confirmation title",
|
||||
description: "This is confirmation description",
|
||||
title: "This is confirmation title",
|
||||
}}
|
||||
description="This is a description"
|
||||
isPending={true}
|
||||
title="This is a title"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import {
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { DynamicHeight } from "../ui/DynamicHeight";
|
||||
|
||||
@@ -58,19 +58,19 @@ export function DangerSettingCard(props: {
|
||||
)}
|
||||
>
|
||||
<Dialog
|
||||
open={isConfirmationDialogOpen}
|
||||
onOpenChange={(v) => {
|
||||
setIsConfirmationDialogOpen(v);
|
||||
if (!v) {
|
||||
props.confirmationDialog.onClose?.();
|
||||
}
|
||||
}}
|
||||
open={isConfirmationDialogOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="gap-2 bg-red-600 font-semibold text-white hover:bg-red-600/80"
|
||||
disabled={props.isDisabled || props.isPending}
|
||||
variant="destructive"
|
||||
>
|
||||
{props.isPending && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
@@ -99,10 +99,10 @@ export function DangerSettingCard(props: {
|
||||
</DialogClose>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="gap-2 bg-red-600 font-semibold text-white hover:bg-red-600/80"
|
||||
onClick={props.buttonOnClick}
|
||||
disabled={props.isPending}
|
||||
onClick={props.buttonOnClick}
|
||||
variant="destructive"
|
||||
>
|
||||
{props.isPending && <Spinner className="size-3" />}
|
||||
{props.buttonLabel}
|
||||
|
||||
@@ -7,9 +7,10 @@ export function DocLink(props: {
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={props.link}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 whitespace-nowrap text-muted-foreground text-sm hover:text-foreground"
|
||||
href={props.link}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<props.icon className="size-4" />
|
||||
{props.label}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import Papa from "papaparse";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
export function ExportToCSVButton(props: {
|
||||
@@ -34,12 +34,12 @@ export function ExportToCSVButton(props: {
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={props.disabled || exportMutation.isPending}
|
||||
className={cn("flex items-center gap-2 border text-xs", props.className)}
|
||||
disabled={props.disabled || exportMutation.isPending}
|
||||
onClick={async () => {
|
||||
exportMutation.mutate();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{exportMutation.isPending ? (
|
||||
<>
|
||||
@@ -58,8 +58,8 @@ export function ExportToCSVButton(props: {
|
||||
|
||||
function convertToCSVFormat(data: { header: string[]; rows: string[][] }) {
|
||||
return Papa.unparse({
|
||||
fields: data.header,
|
||||
data: data.rows,
|
||||
fields: data.header,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/assets/create/_common/file-preview";
|
||||
import { FilePreview } from "@app/team/[team_slug]/[project_slug]/(sidebar)/tokens/create/_common/file-preview";
|
||||
import { FilePlusIcon, UploadIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import {
|
||||
@@ -10,6 +8,8 @@ import {
|
||||
useDropzone,
|
||||
} from "react-dropzone";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface IFileInputProps {
|
||||
accept?: Accept;
|
||||
@@ -60,8 +60,8 @@ export const FileInput: React.FC<IFileInputProps> = ({
|
||||
[setValue],
|
||||
);
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop,
|
||||
accept,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
const helperTextOrFile = helperText
|
||||
@@ -98,15 +98,15 @@ export const FileInput: React.FC<IFileInputProps> = ({
|
||||
{showPreview &&
|
||||
(isDisabled ? (
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: 1,
|
||||
maxWidth: previewMaxWidth,
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-not-allowed bg-card opacity-50",
|
||||
"flex w-full items-center justify-center border border-border hover:bg-accent hover:ring-2 hover:ring-ring",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: 1,
|
||||
maxWidth: previewMaxWidth,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<UploadIcon className="size-6" />
|
||||
@@ -115,23 +115,23 @@ export const FileInput: React.FC<IFileInputProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
aspectRatio: 1,
|
||||
maxWidth: previewMaxWidth,
|
||||
}}
|
||||
className={cn(
|
||||
"relative cursor-pointer overflow-hidden",
|
||||
"flex w-full items-center justify-center border border-border hover:bg-accent hover:ring-2 hover:ring-ring",
|
||||
value ? "bg-transparent" : "bg-card",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
aspectRatio: 1,
|
||||
maxWidth: previewMaxWidth,
|
||||
}}
|
||||
>
|
||||
{value ? (
|
||||
<FilePreview
|
||||
srcOrFile={value}
|
||||
client={client}
|
||||
className="h-full w-full"
|
||||
client={client}
|
||||
imgContainerClassName="h-full w-full"
|
||||
srcOrFile={value}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2.5 text-muted-foreground">
|
||||
@@ -151,7 +151,7 @@ export const FileInput: React.FC<IFileInputProps> = ({
|
||||
{showUploadButton ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{showUploadButton && (
|
||||
<Button variant="outline" disabled={isDisabled} className="gap-2">
|
||||
<Button className="gap-2" disabled={isDisabled} variant="outline">
|
||||
<FilePlusIcon className="size-4" /> {selectOrUpload}{" "}
|
||||
{helperTextOrFile}
|
||||
</Button>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { AsteriskIcon, InfoIcon } from "lucide-react";
|
||||
import type React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
|
||||
export function FormFieldSetup(props: {
|
||||
htmlFor?: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
@@ -7,14 +9,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer } from "../../../../stories/utils";
|
||||
import { BadgeContainer } from "@/storybook/utils";
|
||||
import { GatedSwitch } from "./GatedSwitch";
|
||||
|
||||
const meta = {
|
||||
title: "Billing/GatedSwitch",
|
||||
component: Variants,
|
||||
title: "Billing/GatedSwitch",
|
||||
} satisfies Meta<typeof Variants>;
|
||||
|
||||
export default meta;
|
||||
@@ -46,10 +46,10 @@ function Variants() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Required Plan</Label>
|
||||
<Select
|
||||
value={requiredPlan}
|
||||
onValueChange={(value) =>
|
||||
setRequiredPlan(value as typeof requiredPlan)
|
||||
}
|
||||
value={requiredPlan}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select plan" />
|
||||
@@ -66,8 +66,8 @@ function Variants() {
|
||||
{plans.map((currentPlan) => (
|
||||
<BadgeContainer key={currentPlan} label={`plan: ${currentPlan}`}>
|
||||
<GatedSwitch
|
||||
key={currentPlan}
|
||||
currentPlan={currentPlan}
|
||||
key={currentPlan}
|
||||
requiredPlan={requiredPlan}
|
||||
teamSlug="foo"
|
||||
/>
|
||||
@@ -1,15 +1,12 @@
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Team } from "@/api/team";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { TrackedLinkTW } from "@/components/ui/tracked-link";
|
||||
import { planToTierRecordForGating } from "@/constants/planToTierRecord";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import {
|
||||
TeamPlanBadge,
|
||||
getTeamPlanBadgeLabel,
|
||||
} from "../../../../app/(app)/components/TeamPlanBadge";
|
||||
import { planToTierRecordForGating } from "./planToTierRecord";
|
||||
import { getTeamPlanBadgeLabel, TeamPlanBadge } from "./TeamPlanBadge";
|
||||
|
||||
type SwitchProps = React.ComponentProps<typeof Switch>;
|
||||
|
||||
@@ -45,16 +42,15 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
</p>
|
||||
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button asChild size="sm" className="justify-start gap-2">
|
||||
<TrackedLinkTW
|
||||
target="_blank"
|
||||
<Button asChild className="justify-start gap-2" size="sm">
|
||||
<Link
|
||||
href={`/team/${props.teamSlug}/~/settings/billing?showPlans=true&highlight=${props.requiredPlan}`}
|
||||
category="advancedFeature"
|
||||
label="checkout"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{`Upgrade to ${getTeamPlanBadgeLabel(props.requiredPlan)} plan`}
|
||||
Upgrade to {getTeamPlanBadgeLabel(props.requiredPlan)} plan
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</TrackedLinkTW>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,15 +61,15 @@ export const GatedSwitch: React.FC<GatedSwitchProps> = (
|
||||
{isUpgradeRequired && (
|
||||
<TeamPlanBadge
|
||||
plan={props.requiredPlan}
|
||||
teamSlug={props.teamSlug}
|
||||
postfix="+"
|
||||
teamSlug={props.teamSlug}
|
||||
/>
|
||||
)}
|
||||
<Switch
|
||||
{...props.switchProps}
|
||||
checked={props.switchProps?.checked && !isUpgradeRequired}
|
||||
disabled={props.switchProps?.disabled || isUpgradeRequired}
|
||||
className={cn("disabled:opacity-100", props.switchProps?.className)}
|
||||
disabled={props.switchProps?.disabled || isUpgradeRequired}
|
||||
/>
|
||||
</div>
|
||||
</ToolTipLabel>
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { Meta, StoryObj } from "@storybook/nextjs";
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer } from "../../../stories/utils";
|
||||
import { Spinner } from "../ui/Spinner/Spinner";
|
||||
import { BadgeContainer } from "@/storybook/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { Spinner } from "../ui/Spinner/Spinner";
|
||||
import { Img } from "./Img";
|
||||
|
||||
const meta = {
|
||||
title: "blocks/Img",
|
||||
component: Story,
|
||||
title: "blocks/Img",
|
||||
} satisfies Meta<typeof Story>;
|
||||
|
||||
export default meta;
|
||||
@@ -51,24 +51,24 @@ function Story() {
|
||||
|
||||
<BadgeContainer label="Custom Skeleton">
|
||||
<Img
|
||||
src={undefined}
|
||||
className="size-20"
|
||||
skeleton={
|
||||
<div className="flex items-center justify-center rounded-lg border">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
}
|
||||
className="size-20"
|
||||
src={undefined}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Custom Fallback">
|
||||
<Img
|
||||
className="size-20"
|
||||
fallback={
|
||||
<div className="flex items-center justify-center rounded-lg border">
|
||||
<ImageIcon className="size-10 text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
className="size-20"
|
||||
src="invalid-src"
|
||||
/>
|
||||
</BadgeContainer>
|
||||
@@ -82,7 +82,7 @@ function ToggleTest() {
|
||||
return (
|
||||
<div className="relative flex flex-col gap-10 border p-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="absolute top-6 right-6 inline-flex"
|
||||
onClick={() => {
|
||||
if (src) {
|
||||
setSrc(undefined);
|
||||
@@ -90,7 +90,7 @@ function ToggleTest() {
|
||||
setSrc("https://picsum.photos/400");
|
||||
}
|
||||
}}
|
||||
className="absolute top-6 right-6 inline-flex"
|
||||
variant="outline"
|
||||
>
|
||||
Toggle Src
|
||||
</Button>
|
||||
|
||||