Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c547164320 | ||
|
|
38ae2d3c6b | ||
|
|
d75320191d | ||
|
|
6046d4510d | ||
|
|
f1d32261f0 | ||
|
|
04baa74ad7 | ||
|
|
44bb0034a6 | ||
|
|
cfeba37e40 | ||
|
|
fb446bd099 | ||
|
|
24063af948 | ||
|
|
e2c9d4de34 | ||
|
|
0f6aa6b85d | ||
|
|
9f1a77f4c4 | ||
|
|
158a08aafc | ||
|
|
67354dc848 | ||
|
|
575b7b3a58 | ||
|
|
35ccf8f2e8 | ||
|
|
b5e02a4f10 | ||
|
|
cb71dd597e | ||
|
|
83ec6957f9 | ||
|
|
c0bbc21a84 | ||
|
|
9fcf1b80de | ||
|
|
33ddf56a0a | ||
|
|
f9fa5df6b1 | ||
|
|
0d82fc0dd1 | ||
|
|
ef6029b939 | ||
|
|
004601812a | ||
|
|
cbc0efff31 | ||
|
|
9799b0aae1 | ||
|
|
c3b90a6f1b | ||
|
|
323e9d7a37 | ||
|
|
80ae08bd5a | ||
|
|
0c0d837f23 | ||
|
|
879148f37e | ||
|
|
25b149d3a9 | ||
|
|
47c9c43a87 | ||
|
|
31dce55025 | ||
|
|
b535cc816f | ||
|
|
c3ded8a37c | ||
|
|
257d0bddbf | ||
|
|
5a9a76b7a2 | ||
|
|
e772581044 | ||
|
|
5625ff1f80 | ||
|
|
062504f463 | ||
|
|
853ed473c6 | ||
|
|
f5765819d7 | ||
|
|
f4ca73c4bf | ||
|
|
1f517fa10d | ||
|
|
8887e46a00 | ||
|
|
56301bc50e | ||
|
|
36b4ea5260 | ||
|
|
e1f73c8286 | ||
|
|
c4e7f3b344 | ||
|
|
6235fe76d3 | ||
|
|
d8e9245ecf |
@@ -1,49 +0,0 @@
|
||||
name: Linked Issue
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, ready_for_review]
|
||||
|
||||
env:
|
||||
VALID_ISSUE_PREFIXES: "CORE|TOOL|NEB|INFRA"
|
||||
|
||||
jobs:
|
||||
linear:
|
||||
name: Linear
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for linked issue
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number
|
||||
});
|
||||
|
||||
// Check if contributor is external
|
||||
const isInternalContributor = ['MEMBER', 'OWNER', 'COLLABORATOR'].includes(
|
||||
context.payload.pull_request.author_association
|
||||
);
|
||||
|
||||
// Automatically pass for external contributors
|
||||
if (!isInternalContributor) {
|
||||
console.log('External contributor detected - automatically passing check');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = pr.data.body || '';
|
||||
const branchName = pr.data.head.ref;
|
||||
const issueRegex = new RegExp(`(${process.env.VALID_ISSUE_PREFIXES})-\\d+`, 'i');
|
||||
const branchIssueRegex = new RegExp(`(${process.env.VALID_ISSUE_PREFIXES.toLowerCase()})-\\d+`, 'i');
|
||||
|
||||
if (!issueRegex.test(body) && !branchIssueRegex.test(branchName)) {
|
||||
core.setFailed(
|
||||
`No valid issue reference found. PR body or branch name must contain an issue ID with one of these prefixes: ${process.env.VALID_ISSUE_PREFIXES}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = body.match(issueRegex) || branchName.match(branchIssueRegex);
|
||||
console.log(`Found issue reference: ${matches[0]}`);
|
||||
@@ -13,6 +13,8 @@ module.exports = [
|
||||
"/connect/universal-bridge",
|
||||
"/connect/auth",
|
||||
"/connect/in-app-wallets",
|
||||
// -- storage
|
||||
"/storage",
|
||||
// -- nebula
|
||||
"/nebula",
|
||||
// --insight
|
||||
@@ -53,4 +55,6 @@ module.exports = [
|
||||
"/learn/guides/:guide_slug",
|
||||
"/learn/courses",
|
||||
"/learn/glossary",
|
||||
// -- faucets --
|
||||
"/faucets",
|
||||
];
|
||||
|
||||
@@ -12,7 +12,7 @@ const ContentSecurityPolicy = `
|
||||
style-src 'self' 'unsafe-inline' vercel.live;
|
||||
font-src 'self' vercel.live assets.vercel.com framerusercontent.com fonts.gstatic.com;
|
||||
frame-src * data:;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' 'wasm-unsafe-eval' 'inline-speculation-rules' *.thirdweb.com *.thirdweb-dev.com vercel.live js.stripe.com framerusercontent.com events.framer.com challenges.cloudflare.com static.reo.dev;
|
||||
connect-src * data: blob:;
|
||||
worker-src 'self' blob:;
|
||||
block-all-mixed-content;
|
||||
@@ -153,7 +153,7 @@ const baseNextConfig: NextConfig = {
|
||||
// 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: "/",
|
||||
destination: "https://landing.thirdweb.com",
|
||||
},
|
||||
...FRAMER_PATHS.map((path) => ({
|
||||
source: path,
|
||||
|
||||
+26
-25
@@ -4,7 +4,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "NODE_OPTIONS=--max-old-space-size=6144 next build",
|
||||
"start": "next start",
|
||||
"format": "biome format ./src --write",
|
||||
@@ -45,9 +45,9 @@
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "1.1.8",
|
||||
"@sentry/nextjs": "9.2.0",
|
||||
"@shazow/whatsabi": "^0.19.0",
|
||||
"@tanstack/react-query": "5.66.9",
|
||||
"@sentry/nextjs": "9.3.0",
|
||||
"@shazow/whatsabi": "0.20.0",
|
||||
"@tanstack/react-query": "5.67.1",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@thirdweb-dev/service-utils": "workspace:*",
|
||||
"@vercel/functions": "2.0.0",
|
||||
@@ -61,13 +61,14 @@
|
||||
"date-fns": "4.1.0",
|
||||
"fetch-event-stream": "0.1.5",
|
||||
"flat": "^6.0.1",
|
||||
"framer-motion": "12.4.7",
|
||||
"framer-motion": "12.4.10",
|
||||
"fuse.js": "7.1.0",
|
||||
"idb-keyval": "^6.2.1",
|
||||
"input-otp": "^1.4.1",
|
||||
"ioredis": "^5.5.0",
|
||||
"ipaddr.js": "^2.2.0",
|
||||
"lucide-react": "0.476.0",
|
||||
"next": "15.1.7",
|
||||
"lucide-react": "0.477.0",
|
||||
"next": "15.2.0",
|
||||
"next-plausible": "^3.12.4",
|
||||
"next-themes": "^0.4.4",
|
||||
"nextjs-toploader": "^1.6.12",
|
||||
@@ -93,7 +94,7 @@
|
||||
"shiki": "1.27.0",
|
||||
"sonner": "2.0.1",
|
||||
"spdx-correct": "^3.2.0",
|
||||
"swagger-ui-react": "^5.19.0",
|
||||
"swagger-ui-react": "^5.20.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"thirdweb": "workspace:*",
|
||||
@@ -103,21 +104,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chakra-ui/cli": "^2.4.1",
|
||||
"@chromatic-com/storybook": "3.2.4",
|
||||
"@next/bundle-analyzer": "15.1.7",
|
||||
"@next/eslint-plugin-next": "15.1.7",
|
||||
"@chromatic-com/storybook": "3.2.5",
|
||||
"@next/bundle-analyzer": "15.2.0",
|
||||
"@next/eslint-plugin-next": "15.2.0",
|
||||
"@playwright/test": "1.50.1",
|
||||
"@storybook/addon-essentials": "8.6.0",
|
||||
"@storybook/addon-interactions": "8.6.0",
|
||||
"@storybook/addon-links": "8.6.0",
|
||||
"@storybook/addon-onboarding": "8.6.0",
|
||||
"@storybook/addon-viewport": "8.6.0",
|
||||
"@storybook/blocks": "8.6.0",
|
||||
"@storybook/nextjs": "8.6.0",
|
||||
"@storybook/react": "8.6.0",
|
||||
"@storybook/test": "8.6.0",
|
||||
"@storybook/addon-essentials": "8.6.3",
|
||||
"@storybook/addon-interactions": "8.6.3",
|
||||
"@storybook/addon-links": "8.6.3",
|
||||
"@storybook/addon-onboarding": "8.6.3",
|
||||
"@storybook/addon-viewport": "8.6.3",
|
||||
"@storybook/blocks": "8.6.3",
|
||||
"@storybook/nextjs": "8.6.3",
|
||||
"@storybook/react": "8.6.3",
|
||||
"@storybook/test": "8.6.3",
|
||||
"@types/color": "4.2.0",
|
||||
"@types/node": "22.13.5",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
@@ -129,16 +130,16 @@
|
||||
"@typescript-eslint/eslint-plugin": "7.14.1",
|
||||
"@typescript-eslint/parser": "7.14.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"checkly": "^4.19.1",
|
||||
"checkly": "5.0.1",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-biome": "1.9.4",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-e1e972c-20250221",
|
||||
"eslint-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
|
||||
"eslint-plugin-storybook": "0.11.3",
|
||||
"knip": "5.45.0",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"postcss": "8.5.3",
|
||||
"storybook": "8.6.0",
|
||||
"storybook": "8.6.3",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.7.3"
|
||||
"typescript": "5.8.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,23 @@ export async function getTeams() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getDefaultTeam() {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/teams/~`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
return (await res.json())?.result as Team;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type TeamNebulaWaitList = {
|
||||
onWaitlist: boolean;
|
||||
createdAt: null | string;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
import { BadgeContainer } from "../../../../stories/utils";
|
||||
import { getThirdwebClient } from "../../../constants/thirdweb.server";
|
||||
import { Button } from "../../ui/button";
|
||||
import { ProjectAvatar } from "./ProjectAvatar";
|
||||
|
||||
@@ -17,17 +18,19 @@ export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
const client = getThirdwebClient();
|
||||
|
||||
function Story() {
|
||||
return (
|
||||
<div className="flex flex-col gap-10 p-10">
|
||||
<p> All images below are set with size-6 className </p>
|
||||
|
||||
<BadgeContainer label="No Src - Skeleton">
|
||||
<ProjectAvatar src={undefined} className="size-6" />
|
||||
<ProjectAvatar src={undefined} className="size-6" client={client} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Invalid/Empty Src - BoxIcon Fallback">
|
||||
<ProjectAvatar src={""} className="size-6" />
|
||||
<ProjectAvatar src={""} className="size-6" client={client} />
|
||||
</BadgeContainer>
|
||||
|
||||
<ToggleTest />
|
||||
@@ -62,13 +65,14 @@ function ToggleTest() {
|
||||
<p> Src+Name is: {data ? "set" : "not set"} </p>
|
||||
|
||||
<BadgeContainer label="Valid Src">
|
||||
<ProjectAvatar src={data?.src} className="size-6" />
|
||||
<ProjectAvatar src={data?.src} className="size-6" client={client} />
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="invalid Src">
|
||||
<ProjectAvatar
|
||||
src={data ? "invalid-src" : undefined}
|
||||
className="size-6"
|
||||
client={client}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Img } from "@/components/blocks/Img";
|
||||
import { BoxIcon } from "lucide-react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { resolveSchemeWithErrorHandler } from "../../../lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
export function ProjectAvatar(props: {
|
||||
src: string | undefined;
|
||||
className: string | undefined;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
return (
|
||||
<Img
|
||||
src={props.src}
|
||||
className={cn("rounded-lg border border-border", props.className)}
|
||||
src={
|
||||
resolveSchemeWithErrorHandler({
|
||||
uri: props.src,
|
||||
client: props.client,
|
||||
}) || ""
|
||||
}
|
||||
className={cn("rounded-full border border-border", props.className)}
|
||||
alt={""}
|
||||
fallback={
|
||||
<div className="flex items-center justify-center bg-card">
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useState } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { DynamicHeight } from "../ui/DynamicHeight";
|
||||
|
||||
export function DangerSettingCard(props: {
|
||||
title: string;
|
||||
@@ -24,9 +26,14 @@ export function DangerSettingCard(props: {
|
||||
confirmationDialog: {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
onClose?: () => void;
|
||||
};
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const [isConfirmationDialogOpen, setIsConfirmationDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -50,7 +57,15 @@ export function DangerSettingCard(props: {
|
||||
props.footerClassName,
|
||||
)}
|
||||
>
|
||||
<Dialog>
|
||||
<Dialog
|
||||
open={isConfirmationDialogOpen}
|
||||
onOpenChange={(v) => {
|
||||
setIsConfirmationDialogOpen(v);
|
||||
if (!v) {
|
||||
props.confirmationDialog.onClose?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -62,21 +77,21 @@ export function DangerSettingCard(props: {
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<div className="p-6">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="leading-snug">
|
||||
{props.confirmationDialog.title}
|
||||
</DialogTitle>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DynamicHeight>
|
||||
<div className="p-6">
|
||||
<DialogHeader className="pr-10">
|
||||
<DialogTitle className="leading-snug">
|
||||
{props.confirmationDialog.title}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
{props.confirmationDialog.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogDescription>
|
||||
{props.confirmationDialog.description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{props.confirmationDialog.children}
|
||||
</div>
|
||||
</DynamicHeight>
|
||||
|
||||
<div className="flex justify-end gap-4 border-t bg-card p-6 lg:gap-2">
|
||||
<DialogClose asChild>
|
||||
|
||||
@@ -225,7 +225,7 @@ export const MultiSelect = forwardRef<HTMLButtonElement, MultiSelectProps>(
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="z-[10001] p-0"
|
||||
className="p-0"
|
||||
align="center"
|
||||
sideOffset={10}
|
||||
onEscapeKeyDown={() => setIsPopoverOpen(false)}
|
||||
|
||||
@@ -137,7 +137,7 @@ export const SelectWithSearch = React.forwardRef<
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
className={cn("z-[10001] p-0", popoverContentClassName)}
|
||||
className={cn("p-0", popoverContentClassName)}
|
||||
align={props.align || "center"}
|
||||
side={props.side}
|
||||
sideOffset={10}
|
||||
|
||||
@@ -47,13 +47,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const btnOnlyProps =
|
||||
Comp === "button" ? { type: "button" as const } : undefined;
|
||||
Comp === "button"
|
||||
? { type: props.type || ("button" as const) }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...btnOnlyProps}
|
||||
{...props}
|
||||
{...btnOnlyProps}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background selection:bg-foreground/10 file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground placeholder:text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -76,7 +76,7 @@ export function TabLinks(props: {
|
||||
|
||||
export function TabButtons(props: {
|
||||
tabs: {
|
||||
name: string;
|
||||
name: React.ReactNode;
|
||||
onClick: () => void;
|
||||
isActive: boolean;
|
||||
isEnabled?: boolean;
|
||||
@@ -108,10 +108,11 @@ export function TabButtons(props: {
|
||||
className={cn("flex", props.tabContainerClassName)}
|
||||
ref={containerRef}
|
||||
>
|
||||
{props.tabs.map((tab) => {
|
||||
{props.tabs.map((tab, index) => {
|
||||
return (
|
||||
<Button
|
||||
key={tab.name}
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: tabs don't change order, so index is stable
|
||||
key={index}
|
||||
variant="ghost"
|
||||
ref={tab.isActive ? activeTabRef : undefined}
|
||||
className={cn(
|
||||
|
||||
@@ -172,3 +172,10 @@ input:-webkit-autofill:active {
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
|
||||
/* Remove this when chakra is removed */
|
||||
/* Make chakra modals have same z-index as shadcn dialog */
|
||||
body {
|
||||
--chakra-zIndices-modal: 50;
|
||||
--chakra-zIndices-overlay: 50;
|
||||
}
|
||||
|
||||
@@ -371,7 +371,6 @@ export type RotateSecretKeyAPIReturnType = {
|
||||
data: {
|
||||
secret: string;
|
||||
secretMasked: string;
|
||||
secretHash: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -232,13 +232,15 @@ export function useEngineUpdateDeployment() {
|
||||
|
||||
export type RemoveEngineFromDashboardIParams = {
|
||||
instanceId: string;
|
||||
teamIdOrSlug: string;
|
||||
};
|
||||
|
||||
export async function removeEngineFromDashboard({
|
||||
instanceId,
|
||||
teamIdOrSlug,
|
||||
}: RemoveEngineFromDashboardIParams) {
|
||||
const res = await apiServerProxy({
|
||||
pathname: `/v1/engine/${instanceId}`,
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/engine/${instanceId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
@@ -273,18 +275,20 @@ export async function deleteCloudHostedEngine({
|
||||
}
|
||||
|
||||
export type EditEngineInstanceParams = {
|
||||
teamIdOrSlug: string;
|
||||
instanceId: string;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export async function editEngineInstance({
|
||||
teamIdOrSlug,
|
||||
instanceId,
|
||||
name,
|
||||
url,
|
||||
}: EditEngineInstanceParams) {
|
||||
const res = await apiServerProxy({
|
||||
pathname: `/v1/engine/${instanceId}`,
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/engine/${instanceId}`,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1628,7 +1632,7 @@ export function useEngineSubscriptionsLastBlock(params: {
|
||||
|
||||
interface EngineResourceMetrics {
|
||||
error: string;
|
||||
data: {
|
||||
result: {
|
||||
cpu: number;
|
||||
memory: number;
|
||||
errorRate: ResultItem[];
|
||||
@@ -1637,7 +1641,7 @@ interface EngineResourceMetrics {
|
||||
};
|
||||
}
|
||||
|
||||
export function useEngineSystemMetrics(engineId: string) {
|
||||
export function useEngineSystemMetrics(engineId: string, teamIdOrSlug: string) {
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
return useQuery({
|
||||
@@ -1645,7 +1649,7 @@ export function useEngineSystemMetrics(engineId: string) {
|
||||
queryFn: async () => {
|
||||
const res = await apiServerProxy({
|
||||
method: "GET",
|
||||
pathname: `/v1/engine/${engineId}/metrics`,
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/metrics`,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -1674,14 +1678,14 @@ export interface EngineAlertRule {
|
||||
pausedAt: Date | null;
|
||||
}
|
||||
|
||||
export function useEngineAlertRules(engineId: string) {
|
||||
export function useEngineAlertRules(engineId: string, teamIdOrSlug: string) {
|
||||
return useQuery({
|
||||
queryKey: engineKeys.alertRules(engineId),
|
||||
queryFn: async () => {
|
||||
const res = await apiServerProxy<{
|
||||
data: EngineAlertRule[];
|
||||
}>({
|
||||
pathname: `/v1/engine/${engineId}/alert-rules`,
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/alert-rules`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
@@ -1703,14 +1707,19 @@ export interface EngineAlert {
|
||||
endsAt: Date | null;
|
||||
}
|
||||
|
||||
export function useEngineAlerts(engineId: string, limit: number, offset = 0) {
|
||||
export function useEngineAlerts(
|
||||
engineId: string,
|
||||
teamIdOrSlug: string,
|
||||
limit: number,
|
||||
offset = 0,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: engineKeys.alerts(engineId),
|
||||
queryFn: async () => {
|
||||
const res = await apiServerProxy<{
|
||||
data: EngineAlert[];
|
||||
}>({
|
||||
pathname: `/v1/engine/${engineId}/alerts`,
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/alerts`,
|
||||
searchParams: {
|
||||
limit: `${limit}`,
|
||||
offset: `${offset}`,
|
||||
@@ -1754,14 +1763,17 @@ export type EngineNotificationChannel = {
|
||||
subscriptionRoutes: string[];
|
||||
};
|
||||
|
||||
export function useEngineNotificationChannels(engineId: string) {
|
||||
export function useEngineNotificationChannels(
|
||||
engineId: string,
|
||||
teamIdOrSlug: string,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: engineKeys.notificationChannels(engineId),
|
||||
queryFn: async () => {
|
||||
const res = await apiServerProxy<{
|
||||
data: EngineNotificationChannel[];
|
||||
}>({
|
||||
pathname: `/v1/engine/${engineId}/notification-channels`,
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/notification-channels`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
@@ -1781,7 +1793,10 @@ export interface CreateNotificationChannelInput {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useEngineCreateNotificationChannel(engineId: string) {
|
||||
export function useEngineCreateNotificationChannel(
|
||||
engineId: string,
|
||||
teamIdOrSlug: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
@@ -1789,7 +1804,7 @@ export function useEngineCreateNotificationChannel(engineId: string) {
|
||||
const res = await apiServerProxy<{
|
||||
data: EngineNotificationChannel;
|
||||
}>({
|
||||
pathname: `/v1/engine/${engineId}/notification-channels`,
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/notification-channels`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -1812,13 +1827,16 @@ export function useEngineCreateNotificationChannel(engineId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useEngineDeleteNotificationChannel(engineId: string) {
|
||||
export function useEngineDeleteNotificationChannel(
|
||||
engineId: string,
|
||||
teamIdOrSlug: string,
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (notificationChannelId: string) => {
|
||||
const res = await apiServerProxy({
|
||||
pathname: `/v1/engine/${engineId}/notification-channels/${notificationChannelId}`,
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/engine/${engineId}/notification-channels/${notificationChannelId}`,
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
|
||||
+2
@@ -74,6 +74,7 @@ export async function RouteListCard({
|
||||
<CardHeader className="flex flex-row items-center justify-between p-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
@@ -83,6 +84,7 @@ export async function RouteListCard({
|
||||
<div className="size-8 rounded-full bg-white/10" />
|
||||
)}
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
|
||||
@@ -76,6 +76,7 @@ export async function RouteListRow({
|
||||
<div className="flex items-center gap-1">
|
||||
{resolvedOriginTokenIconUri ? (
|
||||
// For now we're using a normal img tag because the domain for these images is unknown
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedOriginTokenIconUri}
|
||||
alt={originTokenAddress}
|
||||
@@ -110,6 +111,7 @@ export async function RouteListRow({
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{resolvedDestinationTokenIconUri ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={resolvedDestinationTokenIconUri}
|
||||
alt={destinationTokenAddress}
|
||||
|
||||
@@ -109,6 +109,7 @@ async function getTeamsAndProjectsIfLoggedIn() {
|
||||
projects: (await getProjects(team.slug)).map((x) => ({
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
image: x.image,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ export function ModuleCard(props: ModuleCardProps) {
|
||||
open={isUninstallModalOpen}
|
||||
onOpenChange={setIsUninstallModalOpen}
|
||||
>
|
||||
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
|
||||
<DialogContent>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ export const ProposalButton: React.FC<VoteButtonProps> = ({
|
||||
Create Proposal
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000] w-full sm:w-[540px] sm:max-w-[90%] lg:w-[700px]">
|
||||
<SheetContent className="w-full sm:w-[540px] sm:max-w-[90%] lg:w-[700px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Create new proposal</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export const TokenBurnButton: React.FC<TokenBurnButtonProps> = ({
|
||||
<Flame size={16} /> Burn
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000]">
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">Burn tokens</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
+1
-1
@@ -60,7 +60,7 @@ export const TokenClaimButton: React.FC<TokenClaimButtonProps> = ({
|
||||
<GemIcon size={16} /> Claim
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000]">
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">Claim tokens</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ export const TokenMintButton: React.FC<TokenMintButtonProps> = ({
|
||||
<PlusIcon size={16} /> Mint
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000]">
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">
|
||||
Mint additional tokens
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ export const TokenTransferButton: React.FC<TokenTransferButtonProps> = ({
|
||||
<SendIcon size={16} /> Transfer
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="z-[10000]">
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="text-left">Transfer tokens</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -36,6 +36,7 @@ export async function DeployFormForUri(props: DeployFormForUriProps) {
|
||||
projects: (await getProjects(team.slug)).map((x) => ({
|
||||
id: x.id,
|
||||
name: x.name,
|
||||
image: x.image,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -10,6 +10,11 @@ import { useCallback, useState } from "react";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { LazyCreateProjectDialog } from "../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import {
|
||||
getChangelogNotifications,
|
||||
getInboxNotifications,
|
||||
markNotificationAsRead,
|
||||
} from "../../team/components/NotificationButton/fetch-notifications";
|
||||
import {
|
||||
type AccountHeaderCompProps,
|
||||
AccountHeaderDesktopUI,
|
||||
@@ -54,6 +59,9 @@ export function AccountHeader(props: {
|
||||
account: props.account,
|
||||
client,
|
||||
accountAddress: props.accountAddress,
|
||||
getChangelogNotifications: getChangelogNotifications,
|
||||
getInboxNotifications: getInboxNotifications,
|
||||
markNotificationAsRead: markNotificationAsRead,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -62,6 +62,9 @@ function Variants(props: {
|
||||
email: "[email protected]",
|
||||
}}
|
||||
client={client}
|
||||
getChangelogNotifications={() => Promise.resolve([])}
|
||||
getInboxNotifications={() => Promise.resolve([])}
|
||||
markNotificationAsRead={() => Promise.resolve()}
|
||||
/>
|
||||
</div>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -8,6 +8,10 @@ import type { ThirdwebClient } from "thirdweb";
|
||||
import { SecondaryNav } from "../../components/Header/SecondaryNav/SecondaryNav";
|
||||
import { MobileBurgerMenuButton } from "../../components/MobileBurgerMenuButton";
|
||||
import { ThirdwebMiniLogo } from "../../components/ThirdwebMiniLogo";
|
||||
import {
|
||||
NotificationButtonUI,
|
||||
type NotificationMetadata,
|
||||
} from "../../team/components/NotificationButton/NotificationButton";
|
||||
import { TeamAndProjectSelectorPopoverButton } from "../../team/components/TeamHeader/TeamAndProjectSelectorPopoverButton";
|
||||
import { TeamSelectorMobileMenuButton } from "../../team/components/TeamHeader/TeamSelectorMobileMenuButton";
|
||||
|
||||
@@ -20,6 +24,9 @@ export type AccountHeaderCompProps = {
|
||||
account: Pick<Account, "email" | "id" | "image">;
|
||||
client: ThirdwebClient;
|
||||
accountAddress: string;
|
||||
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
|
||||
@@ -59,6 +66,7 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
|
||||
focus="team-selection"
|
||||
createProject={props.createProject}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -70,6 +78,9 @@ export function AccountHeaderDesktopUI(props: AccountHeaderCompProps) {
|
||||
connectButton={props.connectButton}
|
||||
client={props.client}
|
||||
accountAddress={props.accountAddress}
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
@@ -111,13 +122,21 @@ export function AccountHeaderMobileUI(props: AccountHeaderCompProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MobileBurgerMenuButton
|
||||
type="loggedIn"
|
||||
email={props.account?.email}
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationButtonUI
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
|
||||
<MobileBurgerMenuButton
|
||||
type="loggedIn"
|
||||
email={props.account?.email}
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { redirectToBillingPortal } from "@/actions/billing";
|
||||
import { confirmEmailWithOTP } from "@/actions/confirmEmail";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import { updateAccount } from "@/actions/updateAccount";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { doLogout } from "../../login/auth-actions";
|
||||
import { AccountSettingsPageUI } from "./AccountSettingsPageUI";
|
||||
|
||||
export function AccountSettingsPage(props: {
|
||||
account: Account;
|
||||
client: ThirdwebClient;
|
||||
defaultTeamSlug: string;
|
||||
defaultTeamName: string;
|
||||
}) {
|
||||
const router = useDashboardRouter();
|
||||
const activeWallet = useActiveWallet();
|
||||
const { disconnect } = useDisconnect();
|
||||
return (
|
||||
<div>
|
||||
<header className="border-border border-b py-10">
|
||||
@@ -25,8 +33,32 @@ export function AccountSettingsPage(props: {
|
||||
|
||||
<div className="container max-w-[950px] grow pt-8 pb-20">
|
||||
<AccountSettingsPageUI
|
||||
hideDeleteAccount
|
||||
client={props.client}
|
||||
defaultTeamSlug={props.defaultTeamSlug}
|
||||
defaultTeamName={props.defaultTeamName}
|
||||
onAccountDeleted={async () => {
|
||||
await doLogout();
|
||||
if (activeWallet) {
|
||||
disconnect(activeWallet);
|
||||
}
|
||||
router.replace("/login");
|
||||
}}
|
||||
deleteAccount={async () => {
|
||||
try {
|
||||
const res = await apiServerProxy({
|
||||
method: "DELETE",
|
||||
pathname: "/v1/account",
|
||||
});
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return { status: 500 };
|
||||
}
|
||||
}}
|
||||
redirectToBillingPortal={redirectToBillingPortal}
|
||||
updateAccountAvatar={async (file) => {
|
||||
let uri: string | undefined = undefined;
|
||||
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Checkbox, CheckboxWithLabel } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { useState } from "react";
|
||||
@@ -40,6 +48,13 @@ function Variants() {
|
||||
const [isVerifiedEmail, setIsVerifiedEmail] = useState(true);
|
||||
const [sendEmailFails, setSendEmailFails] = useState(false);
|
||||
const [emailConfirmationFails, setEmailConfirmationFails] = useState(false);
|
||||
const [deleteAccountStatusResponse, setDeleteAccountStatusResponse] =
|
||||
useState<400 | 402 | 500 | 200>(200);
|
||||
|
||||
const deleteAccountStub = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { status: deleteAccountStatusResponse };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container flex max-w-[1132px] flex-col gap-10 py-10">
|
||||
@@ -67,9 +82,37 @@ function Variants() {
|
||||
/>
|
||||
Email Confirmation Fails
|
||||
</CheckboxWithLabel>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<Label>Delete Account Response</Label>
|
||||
<Select
|
||||
value={String(deleteAccountStatusResponse)}
|
||||
onValueChange={(value) =>
|
||||
setDeleteAccountStatusResponse(
|
||||
Number(value) as 400 | 402 | 500 | 200,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="min-w-[320px]">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="200">200 - Success</SelectItem>
|
||||
<SelectItem value="400">400 - Active Subscriptions</SelectItem>
|
||||
<SelectItem value="402">402 - Unpaid Invoices</SelectItem>
|
||||
<SelectItem value="500">500 - Unknown Error</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AccountSettingsPageUI
|
||||
defaultTeamSlug="foo"
|
||||
defaultTeamName="Foo"
|
||||
redirectToBillingPortal={async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return { status: 200 };
|
||||
}}
|
||||
account={{
|
||||
name: "John Doe",
|
||||
email: "[email protected]",
|
||||
@@ -96,6 +139,10 @@ function Variants() {
|
||||
throw new Error("Email already exists");
|
||||
}
|
||||
}}
|
||||
deleteAccount={deleteAccountStub}
|
||||
onAccountDeleted={() => {
|
||||
console.log("Account deleted");
|
||||
}}
|
||||
/>
|
||||
<Toaster richColors />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { BillingBillingPortalAction } from "@/actions/billing";
|
||||
import { BillingPortalButton } from "@/components/billing";
|
||||
import { DangerSettingCard } from "@/components/blocks/DangerSettingCard";
|
||||
import { SettingsCard } from "@/components/blocks/SettingsCard";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -29,14 +32,14 @@ import {
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
|
||||
import { EllipsisIcon } from "lucide-react";
|
||||
import { EllipsisIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
@@ -51,12 +54,16 @@ type MinimalAccount = Pick<
|
||||
|
||||
export function AccountSettingsPageUI(props: {
|
||||
account: MinimalAccount;
|
||||
hideDeleteAccount?: boolean;
|
||||
sendEmail: (email: string) => Promise<void>;
|
||||
updateName: (name: string) => Promise<void>;
|
||||
updateEmailWithOTP: (otp: string) => Promise<void>;
|
||||
updateAccountAvatar: (avatar: File | undefined) => Promise<void>;
|
||||
client: ThirdwebClient;
|
||||
deleteAccount: DeleteAccount;
|
||||
onAccountDeleted: () => void;
|
||||
defaultTeamSlug: string;
|
||||
defaultTeamName: string;
|
||||
redirectToBillingPortal: BillingBillingPortalAction;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -76,7 +83,13 @@ export function AccountSettingsPageUI(props: {
|
||||
updateEmailWithOTP={props.updateEmailWithOTP}
|
||||
/>
|
||||
|
||||
{!props.hideDeleteAccount && <DeleteAccountCard />}
|
||||
<DeleteAccountCard
|
||||
deleteAccount={props.deleteAccount}
|
||||
onAccountDeleted={props.onAccountDeleted}
|
||||
defaultTeamSlug={props.defaultTeamSlug}
|
||||
defaultTeamName={props.defaultTeamName}
|
||||
redirectToBillingPortal={props.redirectToBillingPortal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -182,30 +195,43 @@ function AccountNameFormControl(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAccountCard() {
|
||||
const router = useDashboardRouter();
|
||||
const title = "Delete Account";
|
||||
const description =
|
||||
"Permanently remove your Personal Account and all of its contents from the thirdweb platform. This action is not reversible, please continue with caution.";
|
||||
type DeleteAccount = () => Promise<{
|
||||
status: number;
|
||||
}>;
|
||||
|
||||
// TODO: when user can create multiple teams - the error status messaging needs to be updated
|
||||
|
||||
function DeleteAccountCard(props: {
|
||||
deleteAccount: DeleteAccount;
|
||||
onAccountDeleted: () => void;
|
||||
defaultTeamSlug: string;
|
||||
defaultTeamName: string;
|
||||
redirectToBillingPortal: BillingBillingPortalAction;
|
||||
}) {
|
||||
const title = "Delete Account";
|
||||
const description = (
|
||||
<>
|
||||
Permanently delete your thirdweb account, the default team "
|
||||
{props.defaultTeamName}" and all associated data
|
||||
<br />
|
||||
This action is not reversible
|
||||
</>
|
||||
);
|
||||
|
||||
// TODO
|
||||
const deleteAccount = useMutation({
|
||||
mutationFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log("Deleting account");
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.push("/team");
|
||||
mutationFn: props.deleteAccount,
|
||||
onSuccess: (data) => {
|
||||
if (data.status === 200) {
|
||||
props.onAccountDeleted();
|
||||
toast.success("Account deleted successfully");
|
||||
} else {
|
||||
toast.error("Failed to delete account");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function handleDelete() {
|
||||
const promises = deleteAccount.mutateAsync();
|
||||
toast.promise(promises, {
|
||||
success: "Account deleted successfully",
|
||||
error: "Failed to delete account",
|
||||
});
|
||||
deleteAccount.mutate();
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -216,9 +242,77 @@ function DeleteAccountCard() {
|
||||
buttonOnClick={handleDelete}
|
||||
isPending={deleteAccount.isPending}
|
||||
confirmationDialog={{
|
||||
title: "Are you sure you want to delete your account?",
|
||||
description:
|
||||
"This action is not reversible and will delete all of your data.",
|
||||
title: "Delete Account",
|
||||
description: (
|
||||
<span className="block space-y-2">
|
||||
<span className="block">
|
||||
Permanently delete your thirdweb account, the default team "
|
||||
{props.defaultTeamName}" and all associated data
|
||||
</span>
|
||||
<span className="block font-medium">
|
||||
This action is not reversible
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
onClose: () => {
|
||||
deleteAccount.reset();
|
||||
},
|
||||
children: (
|
||||
<>
|
||||
{deleteAccount.data?.status === 400 && (
|
||||
<div className="mt-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to delete account</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span className="mb-1 block">
|
||||
Your default team "{props.defaultTeamName}" has active
|
||||
subscriptions. These subscriptions have to be cancelled
|
||||
first before deleting account
|
||||
</span>
|
||||
<span className="mb-4 block">
|
||||
Reach out to support to help you cancel them
|
||||
</span>
|
||||
<Button
|
||||
variant="default"
|
||||
asChild
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<Link href="/support/create-ticket">
|
||||
Contact Support
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteAccount.data?.status === 402 && (
|
||||
<div className="mt-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Failed to delete account</AlertTitle>
|
||||
<AlertDescription>
|
||||
<span className="mb-4 block">
|
||||
Your default team "{props.defaultTeamName}" has unpaid
|
||||
invoices. These invoices have to be paid before deleting
|
||||
account
|
||||
</span>
|
||||
<BillingPortalButton
|
||||
size="sm"
|
||||
teamSlug={props.defaultTeamSlug}
|
||||
variant="default"
|
||||
redirectPath="/team"
|
||||
redirectToBillingPortal={props.redirectToBillingPortal}
|
||||
>
|
||||
Manage Billing
|
||||
</BillingPortalButton>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getDefaultTeam } from "@/api/team";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { getAuthToken } from "../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../login/loginRedirect";
|
||||
@@ -6,14 +7,23 @@ import { getValidAccount } from "./getAccount";
|
||||
|
||||
export default async function Page() {
|
||||
const pagePath = "/account";
|
||||
const account = await getValidAccount(pagePath);
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
const [defaultTeam, account, token] = await Promise.all([
|
||||
getDefaultTeam(),
|
||||
getValidAccount(pagePath),
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
if (!token || !defaultTeam) {
|
||||
loginRedirect(pagePath);
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountSettingsPage account={account} client={getThirdwebClient(token)} />
|
||||
<AccountSettingsPage
|
||||
account={account}
|
||||
client={getThirdwebClient(token)}
|
||||
defaultTeamSlug={defaultTeam.slug}
|
||||
defaultTeamName={defaultTeam.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import {
|
||||
NotificationButtonUI,
|
||||
type NotificationMetadata,
|
||||
} from "../../../team/components/NotificationButton/NotificationButton";
|
||||
import { ResourcesDropdownButton } from "./ResourcesDropdownButton";
|
||||
import { AccountButton } from "./account-button.client";
|
||||
|
||||
@@ -11,17 +15,27 @@ export function SecondaryNav(props: {
|
||||
connectButton: React.ReactNode;
|
||||
client: ThirdwebClient;
|
||||
accountAddress: string;
|
||||
getChangelogs: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-6">
|
||||
<SecondaryNavLinks />
|
||||
<AccountButton
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationButtonUI
|
||||
getChangelogs={props.getChangelogs}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
<AccountButton
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function AccountButton(props: {
|
||||
<Button
|
||||
size="icon"
|
||||
asChild
|
||||
className="size-10 cursor-pointer rounded-full hover:ring-2 hover:ring-offset-2"
|
||||
className="size-10 cursor-pointer rounded-full hover:ring-2 hover:ring-ring hover:ring-offset-1"
|
||||
variant="ghost"
|
||||
>
|
||||
{/* Don't remove the div */}
|
||||
|
||||
@@ -59,10 +59,10 @@ export function MobileBurgerMenuButton(
|
||||
/> */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!h-auto p-1"
|
||||
className="flex size-10 items-center justify-center rounded-full bg-background p-0"
|
||||
onClick={() => setIsMenuOpen(true)}
|
||||
>
|
||||
<MenuIcon className="size-6 text-muted-foreground" />
|
||||
<MenuIcon className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
|
||||
{isMenuOpen && (
|
||||
@@ -166,7 +166,7 @@ export function MobileBurgerMenuButton(
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
href="/home"
|
||||
className="text-base text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Home Page
|
||||
|
||||
@@ -8,6 +8,7 @@ import NextTopLoader from "nextjs-toploader";
|
||||
import { Suspense } from "react";
|
||||
import { OrganizeContractsToProjectsBanner } from "../components/notices/AnnouncementBanner";
|
||||
import { OpCreditsGrantedModalWrapperServer } from "../components/onboarding/OpCreditsGrantedModalWrapperServer";
|
||||
import { PosthogIdentifierServer } from "../components/wallets/PosthogIdentifierServer";
|
||||
import { EnsureValidConnectedWalletLoginServer } from "./components/EnsureValidConnectedWalletLogin/EnsureValidConnectedWalletLoginServer";
|
||||
import { PostHogProvider } from "./components/root-providers";
|
||||
import { AppRouterProviders } from "./providers";
|
||||
@@ -80,6 +81,9 @@ export default function RootLayout({
|
||||
<Suspense fallback={null}>
|
||||
<EnsureValidConnectedWalletLoginServer />
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<PosthogIdentifierServer />
|
||||
</Suspense>
|
||||
</AppRouterProviders>
|
||||
<DashboardRouterTopProgressBar />
|
||||
<NextTopLoader
|
||||
|
||||
@@ -24,7 +24,7 @@ const LazyOnboardingUI = lazy(
|
||||
() => import("./onboarding/on-boarding-ui.client"),
|
||||
);
|
||||
|
||||
const wallets = [
|
||||
const loginOptions = [
|
||||
inAppWallet({
|
||||
auth: {
|
||||
options: [
|
||||
@@ -45,9 +45,27 @@ const wallets = [
|
||||
createWallet("io.zerion.wallet"),
|
||||
];
|
||||
|
||||
const inAppWalletLoginOptions = [
|
||||
inAppWallet({
|
||||
auth: {
|
||||
options: [
|
||||
"google",
|
||||
"apple",
|
||||
"facebook",
|
||||
"github",
|
||||
"email",
|
||||
"phone",
|
||||
"passkey",
|
||||
"wallet",
|
||||
],
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export function LoginAndOnboardingPage(props: {
|
||||
account: Account | undefined;
|
||||
redirectPath: string;
|
||||
loginWithInAppWallet: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex min-h-dvh flex-col overflow-hidden bg-background">
|
||||
@@ -91,6 +109,7 @@ export function LoginAndOnboardingPage(props: {
|
||||
<LoginAndOnboardingPageContent
|
||||
account={props.account}
|
||||
redirectPath={props.redirectPath}
|
||||
loginWithInAppWallet={props.loginWithInAppWallet}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -99,6 +118,7 @@ export function LoginAndOnboardingPage(props: {
|
||||
export function LoginAndOnboardingPageContent(props: {
|
||||
account: Account | undefined;
|
||||
redirectPath: string;
|
||||
loginWithInAppWallet: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex grow flex-col">
|
||||
@@ -114,6 +134,7 @@ export function LoginAndOnboardingPageContent(props: {
|
||||
<PageContent
|
||||
redirectPath={props.redirectPath}
|
||||
account={props.account}
|
||||
loginWithInAppWallet={props.loginWithInAppWallet}
|
||||
/>
|
||||
</ClientOnly>
|
||||
</main>
|
||||
@@ -139,6 +160,7 @@ function LoadingCard() {
|
||||
function PageContent(props: {
|
||||
redirectPath: string;
|
||||
account: Account | undefined;
|
||||
loginWithInAppWallet: boolean;
|
||||
}) {
|
||||
const [screen, setScreen] = useState<
|
||||
| { id: "login" }
|
||||
@@ -190,7 +212,12 @@ function PageContent(props: {
|
||||
}
|
||||
|
||||
if (connectionStatus !== "connected" || screen.id === "login") {
|
||||
return <CustomConnectEmbed onLogin={onLogin} />;
|
||||
return (
|
||||
<CustomConnectEmbed
|
||||
onLogin={onLogin}
|
||||
loginWithInAppWallet={props.loginWithInAppWallet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (screen.id === "onboarding") {
|
||||
@@ -215,6 +242,7 @@ function PageContent(props: {
|
||||
|
||||
function CustomConnectEmbed(props: {
|
||||
onLogin: () => void;
|
||||
loginWithInAppWallet: boolean;
|
||||
}) {
|
||||
const { theme } = useTheme();
|
||||
const client = useThirdwebClient();
|
||||
@@ -222,6 +250,16 @@ function CustomConnectEmbed(props: {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Turnstile
|
||||
options={{
|
||||
// only show if interaction is required
|
||||
appearance: "interaction-only",
|
||||
// match the theme of the rest of the app
|
||||
theme: theme === "light" ? "light" : "dark",
|
||||
}}
|
||||
siteKey={TURNSTILE_SITE_KEY}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
/>
|
||||
<ConnectEmbed
|
||||
auth={{
|
||||
getLoginPayload,
|
||||
@@ -247,7 +285,9 @@ function CustomConnectEmbed(props: {
|
||||
return isLoggedInResult;
|
||||
},
|
||||
}}
|
||||
wallets={wallets}
|
||||
wallets={
|
||||
props.loginWithInAppWallet ? inAppWalletLoginOptions : loginOptions
|
||||
}
|
||||
client={client}
|
||||
modalSize="wide"
|
||||
theme={getSDKTheme(theme === "light" ? "light" : "dark")}
|
||||
@@ -255,16 +295,6 @@ function CustomConnectEmbed(props: {
|
||||
privacyPolicyUrl="/privacy-policy"
|
||||
termsOfServiceUrl="/terms"
|
||||
/>
|
||||
<Turnstile
|
||||
options={{
|
||||
// only show if interaction is required
|
||||
appearance: "interaction-only",
|
||||
// match the theme of the rest of the app
|
||||
theme: theme === "light" ? "light" : "dark",
|
||||
}}
|
||||
siteKey={TURNSTILE_SITE_KEY}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import { isValidEncodedRedirectPath } from "./isValidEncodedRedirectPath";
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{
|
||||
next?: string;
|
||||
next: string | string[] | undefined;
|
||||
"in-app-wallet": string | string[] | undefined;
|
||||
}>;
|
||||
}) {
|
||||
const nextPath = (await props.searchParams).next;
|
||||
const searchParams = await props.searchParams;
|
||||
const nextPath =
|
||||
typeof searchParams.next === "string" ? searchParams.next : undefined;
|
||||
const account = await getRawAccount();
|
||||
|
||||
// don't redirect away from login page if authToken is already present and onboarding is done
|
||||
@@ -20,6 +23,10 @@ export default async function Page(props: {
|
||||
nextPath && isValidEncodedRedirectPath(nextPath) ? nextPath : "/team";
|
||||
|
||||
return (
|
||||
<LoginAndOnboardingPage account={account} redirectPath={redirectPath} />
|
||||
<LoginAndOnboardingPage
|
||||
account={account}
|
||||
redirectPath={redirectPath}
|
||||
loginWithInAppWallet={searchParams["in-app-wallet"] === "true"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export function NebulaLoginPage(props: {
|
||||
|
||||
{showPage === "connect" && (
|
||||
<LoginAndOnboardingPageContent
|
||||
loginWithInAppWallet={false}
|
||||
account={props.account}
|
||||
redirectPath={
|
||||
message ? `/?prompt=${encodeURIComponent(message)}` : "/"
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
useConnectionManager,
|
||||
} from "thirdweb/react";
|
||||
import { CustomConnectWallet } from "../@3rdweb-sdk/react/components/connect-wallet";
|
||||
import { PosthogIdentifier } from "../components/wallets/PosthogIdentifier";
|
||||
import { isSanctionedAddress } from "../data/eth-sanctioned-addresses";
|
||||
import { useAllChainsData } from "../hooks/chains/allChains";
|
||||
import { SyncChainStores } from "../stores/chainStores";
|
||||
@@ -27,7 +26,6 @@ export function AppRouterProviders(props: { children: React.ReactNode }) {
|
||||
<ThirdwebProvider>
|
||||
<SyncChainDefinitionsToConnectionManager />
|
||||
<TWAutoConnect />
|
||||
<PosthogIdentifier />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
disableTransitionOnChange
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { getWalletConnections } from "@/api/analytics";
|
||||
import { type Project, getProjects } from "@/api/projects";
|
||||
import { getTeamBySlug } from "@/api/team";
|
||||
import { Changelog } from "components/dashboard/Changelog";
|
||||
import { getThirdwebClient } from "@/constants/thirdweb.server";
|
||||
import { subDays } from "date-fns";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getAuthToken } from "../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../login/loginRedirect";
|
||||
import {
|
||||
type ProjectWithAnalytics,
|
||||
TeamProjectsPage,
|
||||
@@ -13,7 +15,14 @@ export default async function Page(props: {
|
||||
params: Promise<{ team_slug: string }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const team = await getTeamBySlug(params.team_slug);
|
||||
const [team, authToken] = await Promise.all([
|
||||
getTeamBySlug(params.team_slug),
|
||||
getAuthToken(),
|
||||
]);
|
||||
|
||||
if (!authToken) {
|
||||
loginRedirect(`/team/${params.team_slug}`);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
redirect("/team");
|
||||
@@ -23,16 +32,21 @@ export default async function Page(props: {
|
||||
const projectsWithTotalWallets = await getProjectsWithAnalytics(projects);
|
||||
|
||||
return (
|
||||
<div className="container flex grow flex-col gap-12 py-8 lg:flex-row">
|
||||
<div className="flex grow flex-col">
|
||||
<h1 className="mb-4 font-semibold text-2xl tracking-tight">Projects</h1>
|
||||
<TeamProjectsPage projects={projectsWithTotalWallets} team={team} />
|
||||
<div className="flex grow flex-col">
|
||||
<div className="border-border border-b py-10">
|
||||
<div className="container">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">
|
||||
Team Overview
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 lg:w-[320px]">
|
||||
<h2 className="mb-4 font-semibold text-2xl tracking-tight">
|
||||
Changelog
|
||||
</h2>
|
||||
<Changelog />
|
||||
|
||||
<div className="container flex grow flex-col pt-8 pb-20">
|
||||
<TeamProjectsPage
|
||||
projects={projectsWithTotalWallets}
|
||||
team={team}
|
||||
client={getThirdwebClient(authToken)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ export default async function Page(props: {
|
||||
}
|
||||
|
||||
const [ecosystem, team] = await Promise.all([
|
||||
fetchEcosystem(params.slug, authToken),
|
||||
fetchEcosystem(params.slug, authToken, params.team_slug),
|
||||
getTeamBySlug(params.team_slug),
|
||||
]);
|
||||
|
||||
|
||||
+7
-2
@@ -10,7 +10,7 @@ export async function EcosystemLayoutSlug({
|
||||
ecosystemLayoutPath,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: { slug: string };
|
||||
params: { slug: string; team_slug: string };
|
||||
ecosystemLayoutPath: string;
|
||||
}) {
|
||||
const authToken = await getAuthToken();
|
||||
@@ -19,7 +19,11 @@ export async function EcosystemLayoutSlug({
|
||||
redirect(ecosystemLayoutPath);
|
||||
}
|
||||
|
||||
const ecosystem = await fetchEcosystem(params.slug, authToken);
|
||||
const ecosystem = await fetchEcosystem(
|
||||
params.slug,
|
||||
authToken,
|
||||
params.team_slug,
|
||||
);
|
||||
|
||||
if (!ecosystem) {
|
||||
redirect(ecosystemLayoutPath);
|
||||
@@ -30,6 +34,7 @@ export async function EcosystemLayoutSlug({
|
||||
<EcosystemHeader
|
||||
ecosystem={ecosystem}
|
||||
ecosystemLayoutPath={ecosystemLayoutPath}
|
||||
teamIdOrSlug={params.team_slug}
|
||||
/>
|
||||
|
||||
<SidebarLayout
|
||||
|
||||
+7
-1
@@ -62,8 +62,11 @@ function EcosystemAlertBanner({ ecosystem }: { ecosystem: Ecosystem }) {
|
||||
function EcosystemSelect(props: {
|
||||
ecosystem: Ecosystem;
|
||||
ecosystemLayoutPath: string;
|
||||
teamIdOrSlug: string;
|
||||
}) {
|
||||
const { data: ecosystems, isPending } = useEcosystemList();
|
||||
const { data: ecosystems, isPending } = useEcosystemList({
|
||||
teamIdOrSlug: props.teamIdOrSlug,
|
||||
});
|
||||
|
||||
return isPending ? (
|
||||
<Skeleton className="h-10 w-full md:w-[160px]" />
|
||||
@@ -109,8 +112,10 @@ function EcosystemSelect(props: {
|
||||
export function EcosystemHeader(props: {
|
||||
ecosystem: Ecosystem;
|
||||
ecosystemLayoutPath: string;
|
||||
teamIdOrSlug: string;
|
||||
}) {
|
||||
const { data: fetchedEcosystem } = useEcosystem({
|
||||
teamIdOrSlug: props.teamIdOrSlug,
|
||||
slug: props.ecosystem.slug,
|
||||
refetchInterval:
|
||||
props.ecosystem.status === "requested"
|
||||
@@ -192,6 +197,7 @@ export function EcosystemHeader(props: {
|
||||
<EcosystemSelect
|
||||
ecosystem={ecosystem}
|
||||
ecosystemLayoutPath={props.ecosystemLayoutPath}
|
||||
teamIdOrSlug={props.teamIdOrSlug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+5
-2
@@ -7,8 +7,11 @@ import { IntegrationPermissionsSection } from "../server/integration-permissions
|
||||
export function EcosystemPermissionsPage({
|
||||
params,
|
||||
authToken,
|
||||
}: { params: { slug: string }; authToken: string }) {
|
||||
const { data: ecosystem } = useEcosystem({ slug: params.slug });
|
||||
}: { params: { slug: string; team_slug: string }; authToken: string }) {
|
||||
const { data: ecosystem } = useEcosystem({
|
||||
slug: params.slug,
|
||||
teamIdOrSlug: params.team_slug,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export function UpdatePartnerModal({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
|
||||
<DialogContent>
|
||||
<DialogHeader className="mb-2">
|
||||
<DialogTitle>Update {partner.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
+4
-2
@@ -3,21 +3,23 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import type { Ecosystem } from "../../../types";
|
||||
|
||||
export function useEcosystem({
|
||||
teamIdOrSlug,
|
||||
slug,
|
||||
refetchInterval,
|
||||
refetchOnWindowFocus,
|
||||
initialData,
|
||||
}: {
|
||||
teamIdOrSlug: string;
|
||||
slug: string;
|
||||
refetchInterval?: number;
|
||||
refetchOnWindowFocus?: boolean;
|
||||
initialData?: Ecosystem;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: ["ecosystems", slug],
|
||||
queryKey: ["ecosystems", teamIdOrSlug, slug],
|
||||
queryFn: async () => {
|
||||
const res = await apiServerProxy({
|
||||
pathname: `/v1/ecosystem-wallet/${slug}`,
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
|
||||
+10
-5
@@ -31,9 +31,13 @@ const formSchema = z.object({
|
||||
.refine((name) => /^[a-zA-Z0-9 ]*$/.test(name), {
|
||||
message: "Name can only contain letters, numbers and spaces",
|
||||
}),
|
||||
logo: z.instanceof(File, {
|
||||
message: "Logo is required",
|
||||
}),
|
||||
logo: z
|
||||
.instanceof(File, {
|
||||
message: "Logo is required",
|
||||
})
|
||||
.refine((file) => file.size <= 500 * 1024, {
|
||||
message: "Logo size must be less than 500KB",
|
||||
}),
|
||||
permission: z.union([z.literal("PARTNER_WHITELIST"), z.literal("ANYONE")]),
|
||||
});
|
||||
|
||||
@@ -118,8 +122,9 @@ export function CreateEcosystemForm(props: { teamSlug: string }) {
|
||||
accept="image/png, image/jpeg"
|
||||
onUpload={(files) => {
|
||||
if (files[0]) {
|
||||
form.setValue("logo", files[0]);
|
||||
form.clearErrors("logo");
|
||||
form.setValue("logo", files[0], {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
+7
-3
@@ -3,13 +3,17 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useActiveAccount } from "thirdweb/react";
|
||||
import type { Ecosystem } from "../types";
|
||||
|
||||
export function useEcosystemList() {
|
||||
export function useEcosystemList({
|
||||
teamIdOrSlug,
|
||||
}: {
|
||||
teamIdOrSlug: string;
|
||||
}) {
|
||||
const address = useActiveAccount()?.address;
|
||||
return useQuery({
|
||||
queryKey: ["ecosystems", address],
|
||||
queryKey: ["ecosystems", teamIdOrSlug, address],
|
||||
queryFn: async () => {
|
||||
const res = await apiServerProxy({
|
||||
pathname: "/v1/ecosystem-wallet/list",
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
|
||||
method: "GET",
|
||||
});
|
||||
|
||||
|
||||
@@ -20,10 +20,12 @@ export default async function Page(props: {
|
||||
loginRedirect(ecosystemLayoutPath);
|
||||
}
|
||||
|
||||
const ecosystems = await fetchEcosystemList(authToken).catch((err) => {
|
||||
console.error("failed to fetch ecosystems", err);
|
||||
return [];
|
||||
});
|
||||
const ecosystems = await fetchEcosystemList(authToken, team_slug).catch(
|
||||
(err) => {
|
||||
console.error("failed to fetch ecosystems", err);
|
||||
return [];
|
||||
},
|
||||
);
|
||||
if (ecosystems[0]) {
|
||||
redirect(`${ecosystemLayoutPath}/${ecosystems[0].slug}`);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { Ecosystem } from "../types";
|
||||
|
||||
export async function fetchEcosystem(slug: string, authToken: string) {
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/ecosystem-wallet/${slug}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
export async function fetchEcosystem(
|
||||
slug: string,
|
||||
authToken: string,
|
||||
teamIdOrSlug: string,
|
||||
) {
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/ecosystem-wallet/${slug}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
console.error(data);
|
||||
|
||||
+11
-5
@@ -1,12 +1,18 @@
|
||||
import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { Ecosystem } from "../types";
|
||||
|
||||
export async function fetchEcosystemList(authToken: string) {
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/ecosystem-wallet/list`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
export async function fetchEcosystemList(
|
||||
authToken: string,
|
||||
teamIdOrSlug: string,
|
||||
) {
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${teamIdOrSlug}/ecosystem-wallet`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
|
||||
+6
-3
@@ -28,12 +28,15 @@ const formSchema = z.object({
|
||||
|
||||
type ImportEngineParams = z.infer<typeof formSchema>;
|
||||
|
||||
async function importEngine(data: ImportEngineParams) {
|
||||
async function importEngine({
|
||||
teamIdOrSlug,
|
||||
...data
|
||||
}: ImportEngineParams & { teamIdOrSlug: string }) {
|
||||
// Instance URLs should end with a /.
|
||||
const url = data.url.endsWith("/") ? data.url : `${data.url}/`;
|
||||
|
||||
const res = await apiServerProxy({
|
||||
pathname: "/v1/engine",
|
||||
pathname: `/v1/teams/${teamIdOrSlug}/engine`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@@ -59,7 +62,7 @@ export function EngineImportCard(props: {
|
||||
<EngineImportCardUI
|
||||
prefillImportUrl={props.prefillImportUrl}
|
||||
importEngine={async (params) => {
|
||||
await importEngine(params);
|
||||
await importEngine({ ...params, teamIdOrSlug: props.teamSlug });
|
||||
router.push(`/team/${props.teamSlug}/~/engine`);
|
||||
}}
|
||||
/>
|
||||
|
||||
+15
@@ -74,6 +74,7 @@ type RemovedEngineFromDashboard = (
|
||||
) => Promise<void>;
|
||||
|
||||
export function EngineInstancesTable(props: {
|
||||
teamIdOrSlug: string;
|
||||
instances: EngineInstance[];
|
||||
engineLinkPrefix: string;
|
||||
}) {
|
||||
@@ -81,6 +82,7 @@ export function EngineInstancesTable(props: {
|
||||
|
||||
return (
|
||||
<EngineInstancesTableUI
|
||||
teamIdOrSlug={props.teamIdOrSlug}
|
||||
instances={props.instances}
|
||||
engineLinkPrefix={props.engineLinkPrefix}
|
||||
deleteCloudHostedEngine={async (params) => {
|
||||
@@ -100,6 +102,7 @@ export function EngineInstancesTable(props: {
|
||||
}
|
||||
|
||||
export function EngineInstancesTableUI(props: {
|
||||
teamIdOrSlug: string;
|
||||
instances: EngineInstance[];
|
||||
engineLinkPrefix: string;
|
||||
deleteCloudHostedEngine: DeletedCloudHostedEngine;
|
||||
@@ -124,6 +127,7 @@ export function EngineInstancesTableUI(props: {
|
||||
{props.instances.map((instance) => (
|
||||
<EngineInstanceRow
|
||||
key={instance.id}
|
||||
teamIdOrSlug={props.teamIdOrSlug}
|
||||
instance={instance}
|
||||
engineLinkPrefix={props.engineLinkPrefix}
|
||||
deleteCloudHostedEngine={props.deleteCloudHostedEngine}
|
||||
@@ -145,6 +149,7 @@ export function EngineInstancesTableUI(props: {
|
||||
}
|
||||
|
||||
function EngineInstanceRow(props: {
|
||||
teamIdOrSlug: string;
|
||||
instance: EngineInstance;
|
||||
engineLinkPrefix: string;
|
||||
deleteCloudHostedEngine: DeletedCloudHostedEngine;
|
||||
@@ -187,6 +192,7 @@ function EngineInstanceRow(props: {
|
||||
</TableRow>
|
||||
|
||||
<EditModal
|
||||
teamIdOrSlug={props.teamIdOrSlug}
|
||||
instance={instance}
|
||||
open={isEditModalOpen}
|
||||
onOpenChange={setIsEditModalOpen}
|
||||
@@ -194,6 +200,7 @@ function EngineInstanceRow(props: {
|
||||
/>
|
||||
|
||||
<RemoveModal
|
||||
teamIdOrSlug={props.teamIdOrSlug}
|
||||
instance={instance}
|
||||
onOpenChange={setIsRemoveModalOpen}
|
||||
open={isRemoveModalOpen}
|
||||
@@ -349,6 +356,7 @@ function EngineActionsDropdown(props: {
|
||||
|
||||
function EditModal(props: {
|
||||
open: boolean;
|
||||
teamIdOrSlug: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
instance: EngineInstance;
|
||||
editEngineInstance: EditedEngineInstance;
|
||||
@@ -357,6 +365,7 @@ function EditModal(props: {
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<EditModalContent
|
||||
teamIdOrSlug={props.teamIdOrSlug}
|
||||
instance={props.instance}
|
||||
editEngineInstance={props.editEngineInstance}
|
||||
closeModal={() => props.onOpenChange(false)}
|
||||
@@ -372,6 +381,7 @@ const editEngineFormSchema = z.object({
|
||||
});
|
||||
|
||||
function EditModalContent(props: {
|
||||
teamIdOrSlug: string;
|
||||
instance: EngineInstance;
|
||||
editEngineInstance: EditedEngineInstance;
|
||||
closeModal: () => void;
|
||||
@@ -396,6 +406,7 @@ function EditModalContent(props: {
|
||||
onSubmit={form.handleSubmit((data) =>
|
||||
editInstance.mutate(
|
||||
{
|
||||
teamIdOrSlug: props.teamIdOrSlug,
|
||||
instanceId: props.instance.id,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
@@ -475,6 +486,7 @@ function EditModalContent(props: {
|
||||
}
|
||||
|
||||
function RemoveModal(props: {
|
||||
teamIdOrSlug: string;
|
||||
instance: EngineInstance;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -491,6 +503,7 @@ function RemoveModal(props: {
|
||||
(instance.status === "active" && !instance.deploymentId) ? (
|
||||
<RemoveEngineFromDashboardModalContent
|
||||
instance={instance}
|
||||
teamIdOrSlug={props.teamIdOrSlug}
|
||||
close={() => onOpenChange(false)}
|
||||
removeEngineFromDashboard={props.removeEngineFromDashboard}
|
||||
/>
|
||||
@@ -507,6 +520,7 @@ function RemoveModal(props: {
|
||||
}
|
||||
|
||||
function RemoveEngineFromDashboardModalContent(props: {
|
||||
teamIdOrSlug: string;
|
||||
instance: EngineInstance;
|
||||
close: () => void;
|
||||
removeEngineFromDashboard: RemovedEngineFromDashboard;
|
||||
@@ -552,6 +566,7 @@ function RemoveEngineFromDashboardModalContent(props: {
|
||||
removeFromDashboard.mutate(
|
||||
{
|
||||
instanceId: instance.id,
|
||||
teamIdOrSlug: props.teamIdOrSlug,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
||||
+1
@@ -12,6 +12,7 @@ export const EngineInstancesList = (props: {
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<EngineInstancesTable
|
||||
teamIdOrSlug={props.team_slug}
|
||||
instances={props.instances}
|
||||
engineLinkPrefix={engineLinkPrefix}
|
||||
/>
|
||||
|
||||
@@ -29,7 +29,10 @@ export default async function Page(props: {
|
||||
loginRedirect(`/team/${params.team_slug}/~/engine`);
|
||||
}
|
||||
|
||||
const res = await getEngineInstances({ authToken });
|
||||
const res = await getEngineInstances({
|
||||
authToken,
|
||||
teamIdOrSlug: params.team_slug,
|
||||
});
|
||||
|
||||
return (
|
||||
<EngineInstancesList
|
||||
|
||||
+3
-9
@@ -132,10 +132,7 @@ const ChangeVersionModal = (props: {
|
||||
// Self-hosted modal: prompt to update manually.
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] max-w-[400px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="mb-6 pr-4 font-semibold text-2xl tracking-tight">
|
||||
Update your self-hosted Engine
|
||||
@@ -182,10 +179,7 @@ const ChangeVersionModal = (props: {
|
||||
// For cloud-hosted, prompt the user to select a version to update to.
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] max-w-[400px]"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Update Engine version</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -198,7 +192,7 @@ const ChangeVersionModal = (props: {
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{serverVersions.map(({ name, createdAt }, idx) => {
|
||||
const isCurrentVersion = name === currentVersion;
|
||||
|
||||
+3
-6
@@ -91,10 +91,7 @@ export function EngineAlertDialogForm(props: {
|
||||
|
||||
return (
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="p-0">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="p-6">
|
||||
@@ -120,7 +117,7 @@ export function EngineAlertDialogForm(props: {
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{alertRuleOptions.map((option) => (
|
||||
<SelectItem
|
||||
@@ -161,7 +158,7 @@ export function EngineAlertDialogForm(props: {
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="Select Notification Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{Object.entries(
|
||||
EngineNotificationChannelTypeConfig,
|
||||
|
||||
+7
-1
@@ -9,8 +9,12 @@ import { RecentEngineAlertsSection } from "./RecentEngineAlerts";
|
||||
|
||||
export function EngineAlertsPage(props: {
|
||||
instance: EngineInstance;
|
||||
teamIdOrSlug: string;
|
||||
}) {
|
||||
const alertRulesQuery = useEngineAlertRules(props.instance.id);
|
||||
const alertRulesQuery = useEngineAlertRules(
|
||||
props.instance.id,
|
||||
props.teamIdOrSlug,
|
||||
);
|
||||
const alertRules = alertRulesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
@@ -19,12 +23,14 @@ export function EngineAlertsPage(props: {
|
||||
alertRules={alertRules}
|
||||
engineId={props.instance.id}
|
||||
alertRulesIsLoading={alertRulesQuery.isLoading}
|
||||
teamIdOrSlug={props.teamIdOrSlug}
|
||||
/>
|
||||
<div className="h-8" />
|
||||
<RecentEngineAlertsSection
|
||||
alertRules={alertRules}
|
||||
engineId={props.instance.id}
|
||||
alertRulesIsLoading={alertRulesQuery.isLoading}
|
||||
teamIdOrSlug={props.teamIdOrSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+4
@@ -43,19 +43,23 @@ type CreateAlertMutation = UseMutationResult<
|
||||
>;
|
||||
|
||||
export function ManageEngineAlertsSection(props: {
|
||||
teamIdOrSlug: string;
|
||||
alertRules: EngineAlertRule[];
|
||||
alertRulesIsLoading: boolean;
|
||||
engineId: string;
|
||||
}) {
|
||||
const notificationChannelsQuery = useEngineNotificationChannels(
|
||||
props.engineId,
|
||||
props.teamIdOrSlug,
|
||||
);
|
||||
const deleteAlertMutation = useEngineDeleteNotificationChannel(
|
||||
props.engineId,
|
||||
props.teamIdOrSlug,
|
||||
);
|
||||
|
||||
const createAlertMutation = useEngineCreateNotificationChannel(
|
||||
props.engineId,
|
||||
props.teamIdOrSlug,
|
||||
);
|
||||
|
||||
// not passing the mutation to avoid multiple rows sharing the same mutation state, we create the new mutation for each row instead in each component instead
|
||||
|
||||
+7
-1
@@ -23,10 +23,16 @@ export function RecentEngineAlertsSection(props: {
|
||||
alertRules: EngineAlertRule[];
|
||||
alertRulesIsLoading: boolean;
|
||||
engineId: string;
|
||||
teamIdOrSlug: string;
|
||||
}) {
|
||||
// TODO - pagination
|
||||
// required : return the total number of alerts in response from API
|
||||
const alertsQuery = useEngineAlerts(props.engineId, 100, 0);
|
||||
const alertsQuery = useEngineAlerts(
|
||||
props.engineId,
|
||||
props.teamIdOrSlug,
|
||||
100,
|
||||
0,
|
||||
);
|
||||
const alerts = alertsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
|
||||
+3
-1
@@ -9,5 +9,7 @@ export default async function Page(props: EngineInstancePageProps) {
|
||||
teamSlug: params.team_slug,
|
||||
});
|
||||
|
||||
return <EngineAlertsPage instance={instance} />;
|
||||
return (
|
||||
<EngineAlertsPage instance={instance} teamIdOrSlug={params.team_slug} />
|
||||
);
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ export const EngineConfiguration: React.FC<EngineConfigurationProps> = ({
|
||||
instanceUrl={instance.url}
|
||||
authToken={authToken}
|
||||
/>
|
||||
<EngineSystem instance={instance} />
|
||||
<EngineSystem instance={instance} teamIdOrSlug={teamSlug} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+8
-4
@@ -6,11 +6,15 @@ import {
|
||||
|
||||
interface EngineSystemProps {
|
||||
instance: EngineInstance;
|
||||
teamIdOrSlug: string;
|
||||
}
|
||||
|
||||
export const EngineSystem: React.FC<EngineSystemProps> = ({ instance }) => {
|
||||
export const EngineSystem: React.FC<EngineSystemProps> = ({
|
||||
instance,
|
||||
teamIdOrSlug,
|
||||
}) => {
|
||||
const healthQuery = useEngineSystemHealth(instance.url);
|
||||
const metricsQuery = useEngineSystemMetrics(instance.id);
|
||||
const metricsQuery = useEngineSystemMetrics(instance.id, teamIdOrSlug);
|
||||
if (!healthQuery.data) {
|
||||
return null;
|
||||
}
|
||||
@@ -21,9 +25,9 @@ export const EngineSystem: React.FC<EngineSystemProps> = ({ instance }) => {
|
||||
<br />
|
||||
Enabled: {healthQuery.data.features?.join(", ")}
|
||||
<br />
|
||||
CPU: {metricsQuery.data?.data?.cpu?.toFixed(2) ?? "..."}%
|
||||
CPU: {metricsQuery.data?.result?.cpu?.toFixed(2) ?? "..."}%
|
||||
<br />
|
||||
Memory: {metricsQuery.data?.data?.memory?.toFixed(0) ?? "..."}MB
|
||||
Memory: {metricsQuery.data?.result?.memory?.toFixed(0) ?? "..."}MB
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
+5
-4
@@ -7,7 +7,7 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
import { ChakraProvider } from "@chakra-ui/react";
|
||||
import { ChakraProviderSetup } from "../../../../../../../../@/components/ChakraProviderSetup";
|
||||
import { getValidAccount } from "../../../../../../../account/settings/getAccount";
|
||||
import { getAuthToken } from "../../../../../../../api/lib/getAuthToken";
|
||||
import { loginRedirect } from "../../../../../../../login/loginRedirect";
|
||||
@@ -33,6 +33,7 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
const instance = await getEngineInstance({
|
||||
teamIdOrSlug: params.team_slug,
|
||||
authToken,
|
||||
engineId: params.engineId,
|
||||
accountId: account.id,
|
||||
@@ -54,7 +55,7 @@ export default async function Layout(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<ChakraProvider>
|
||||
<ChakraProviderSetup>
|
||||
<div className="flex grow flex-col">
|
||||
<EngineInstanceHeader
|
||||
instance={instance}
|
||||
@@ -77,7 +78,7 @@ export default async function Layout(props: {
|
||||
</EnsureEnginePermission>
|
||||
</EngineSidebarLayout>
|
||||
</div>
|
||||
</ChakraProvider>
|
||||
</ChakraProviderSetup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +93,7 @@ function EngineInstanceHeader(props: {
|
||||
: instance.url;
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="border-border border-b">
|
||||
<div className="container py-6">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
|
||||
+5
-3
@@ -23,7 +23,7 @@ export const EngineSystemMetrics: React.FC<EngineStatusProps> = ({
|
||||
teamSlug,
|
||||
authToken,
|
||||
}) => {
|
||||
const systemMetricsQuery = useEngineSystemMetrics(instance.id);
|
||||
const systemMetricsQuery = useEngineSystemMetrics(instance.id, teamSlug);
|
||||
const queueMetricsQuery = useEngineQueueMetrics({
|
||||
authToken,
|
||||
instanceUrl: instance.url,
|
||||
@@ -69,8 +69,10 @@ export const EngineSystemMetrics: React.FC<EngineStatusProps> = ({
|
||||
<div className="mt-10 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<Healthcheck instance={instance} />
|
||||
</div>
|
||||
<StatusCodes datapoints={systemMetricsQuery.data.data.statusCodes} />
|
||||
<ErrorRate datapoints={systemMetricsQuery.data.data.errorRate} />
|
||||
<StatusCodes
|
||||
datapoints={systemMetricsQuery.data.result.statusCodes}
|
||||
/>
|
||||
<ErrorRate datapoints={systemMetricsQuery.data.result.errorRate} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
+2
-5
@@ -129,10 +129,7 @@ export const CreateBackendWalletButton: React.FC<
|
||||
Create
|
||||
</Button>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="p-6">
|
||||
@@ -162,7 +159,7 @@ export const CreateBackendWalletButton: React.FC<
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{walletTypeOptions.map((option) => (
|
||||
<SelectItem key={option.key} value={option.key}>
|
||||
|
||||
+2
-5
@@ -147,10 +147,7 @@ export const ImportBackendWalletButton: React.FC<
|
||||
</Button>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent
|
||||
className="z-[10001] p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="p-0">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="p-6">
|
||||
@@ -175,7 +172,7 @@ export const ImportBackendWalletButton: React.FC<
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{walletTypeOptions.map((option) => (
|
||||
<SelectItem key={option.key} value={option.key}>
|
||||
|
||||
+2
-5
@@ -67,10 +67,7 @@ export const CredentialForm = ({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="z-[10001] overflow-hidden p-0"
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((data) => {
|
||||
@@ -114,7 +111,7 @@ export const CredentialForm = ({
|
||||
<SelectTrigger className="bg-card">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[10001]">
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{CREDENTIAL_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
|
||||
+10
-6
@@ -2,6 +2,7 @@ import { API_SERVER_URL } from "@/constants/env";
|
||||
import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
|
||||
export async function getEngineInstance(params: {
|
||||
teamIdOrSlug: string;
|
||||
authToken: string;
|
||||
engineId: string;
|
||||
accountId: string;
|
||||
@@ -19,11 +20,14 @@ export async function getEngineInstance(params: {
|
||||
return sandboxEngine;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/engine/${params.engineId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${params.teamIdOrSlug}/engine/${params.engineId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await res.text();
|
||||
@@ -33,8 +37,8 @@ export async function getEngineInstance(params: {
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
data: EngineInstance;
|
||||
result: EngineInstance;
|
||||
};
|
||||
|
||||
return json.data;
|
||||
return json.result;
|
||||
}
|
||||
|
||||
+1
@@ -20,6 +20,7 @@ export async function engineInstancePageHandler(params: {
|
||||
}
|
||||
|
||||
const instance = await getEngineInstance({
|
||||
teamIdOrSlug: params.teamSlug,
|
||||
authToken,
|
||||
engineId: params.engineId,
|
||||
accountId: account.id,
|
||||
|
||||
+10
-8
@@ -3,12 +3,16 @@ import type { EngineInstance } from "@3rdweb-sdk/react/hooks/useEngine";
|
||||
|
||||
export async function getEngineInstances(params: {
|
||||
authToken: string;
|
||||
teamIdOrSlug: string;
|
||||
}) {
|
||||
const res = await fetch(`${API_SERVER_URL}/v1/engine`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
const res = await fetch(
|
||||
`${API_SERVER_URL}/v1/teams/${params.teamIdOrSlug}/engine`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.authToken}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const errorMessage = await res.text();
|
||||
@@ -20,12 +24,10 @@ export async function getEngineInstances(params: {
|
||||
}
|
||||
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
instances: EngineInstance[];
|
||||
};
|
||||
result: EngineInstance[];
|
||||
};
|
||||
|
||||
return {
|
||||
data: json.data.instances,
|
||||
data: json.result,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,33 +6,30 @@ import { ProjectAvatar } from "@/components/blocks/Avatars/ProjectAvatar";
|
||||
import { PaginationButtons } from "@/components/pagination-buttons";
|
||||
import { CopyTextButton } from "@/components/ui/CopyTextButton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
EllipsisVerticalIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
} from "lucide-react";
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LazyCreateProjectDialog } from "components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { formatDate } from "date-fns";
|
||||
import { PlusIcon, SearchIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
|
||||
type SortById = "name" | "createdAt" | "monthlyActiveUsers";
|
||||
|
||||
@@ -43,6 +40,7 @@ export type ProjectWithAnalytics = Project & {
|
||||
export function TeamProjectsPage(props: {
|
||||
projects: ProjectWithAnalytics[];
|
||||
team: Team;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { projects } = props;
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -80,7 +78,7 @@ export function TeamProjectsPage(props: {
|
||||
return _projectsToShow;
|
||||
}, [searchTerm, sortBy, projects]);
|
||||
|
||||
const pageSize = 8;
|
||||
const pageSize = 10;
|
||||
const [page, setPage] = useState(1);
|
||||
const paginatedProjects = sortedProjects.slice(
|
||||
(page - 1) * pageSize,
|
||||
@@ -92,78 +90,135 @@ export function TeamProjectsPage(props: {
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
{/* Filters + Add New */}
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onValueChange={(v) => {
|
||||
setSearchTerm(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<SelectBy
|
||||
value={sortBy}
|
||||
onChange={(v) => {
|
||||
setSortBy(v);
|
||||
<div className="relative flex flex-col gap-5 rounded-t-lg pb-4 lg:flex-row lg:items-center lg:justify-between lg:border lg:border-b-0 lg:bg-card lg:px-6 lg:py-6">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Projects</h2>
|
||||
|
||||
{/* Filters + Add New */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onValueChange={(v) => {
|
||||
setSearchTerm(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<AddNewButton
|
||||
createProject={() => setIsCreateProjectDialogOpen(true)}
|
||||
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<SelectBy
|
||||
value={sortBy}
|
||||
onChange={(v) => {
|
||||
setSortBy(v);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<AddNewButton
|
||||
createProject={() => setIsCreateProjectDialogOpen(true)}
|
||||
teamMembersSettingsPath={`/team/${props.team.slug}/~/settings/members`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6" />
|
||||
|
||||
{/* Projects */}
|
||||
{/* Projects Table */}
|
||||
{paginatedProjects.length === 0 ? (
|
||||
<>
|
||||
{searchTerm !== "" ? (
|
||||
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
|
||||
<div className="flex min-h-[450px] grow items-center justify-center border border-border bg-card">
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="mb-5 text-center">No projects found</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[450px] grow items-center justify-center rounded-lg border border-border">
|
||||
<div className="flex min-h-[450px] grow items-center justify-center rounded-b-lg border border-border bg-card">
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="mb-5 text-center">No projects created</p>
|
||||
<p className="mb-5 text-center font-semibold text-lg">
|
||||
No projects created
|
||||
</p>
|
||||
<Button
|
||||
className="gap-2"
|
||||
className="gap-2 bg-background"
|
||||
onClick={() => setIsCreateProjectDialogOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
Create a Project
|
||||
Create Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
{paginatedProjects.map((project) => {
|
||||
return (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
team_slug={props.team.slug}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<TableContainer
|
||||
className={cn(
|
||||
"lg:rounded-t-none",
|
||||
showPagination && "rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="max-w-[300px]">Project</TableHead>
|
||||
<TableHead>Monthly Active Users</TableHead>
|
||||
<TableHead className="w-[250px]">Client ID</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginatedProjects.map((project) => (
|
||||
<TableRow
|
||||
key={project.id}
|
||||
linkBox
|
||||
className="hover:bg-accent/50"
|
||||
>
|
||||
<TableCell>
|
||||
<Link
|
||||
className="flex items-center gap-3 before:absolute before:inset-0"
|
||||
href={`/team/${props.team.slug}/${project.slug}`}
|
||||
>
|
||||
<ProjectAvatar
|
||||
className="size-8 rounded-full"
|
||||
src={project.image || ""}
|
||||
client={props.client}
|
||||
/>
|
||||
<span className="font-medium text-sm">
|
||||
{project.name}
|
||||
</span>
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{project.monthlyActiveUsers === 0 ? (
|
||||
<span className="text-muted-foreground">No Users</span>
|
||||
) : (
|
||||
project.monthlyActiveUsers
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CopyTextButton
|
||||
textToCopy={project.publishableKey}
|
||||
textToShow={`${project.publishableKey.slice(0, 4)}...${project.publishableKey.slice(-4)}`}
|
||||
copyIconPosition="right"
|
||||
tooltip={undefined}
|
||||
variant="ghost"
|
||||
className="-translate-x-2 z-10 font-mono text-muted-foreground"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDate(new Date(project.createdAt), "MMM d, yyyy")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
{showPagination && (
|
||||
<div className="py-6">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
{showPagination && (
|
||||
<div className="rounded-b-lg border border-t-0 bg-card p-4">
|
||||
<PaginationButtons
|
||||
activePage={page}
|
||||
onPageClick={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -184,64 +239,6 @@ export function TeamProjectsPage(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard(props: {
|
||||
project: ProjectWithAnalytics;
|
||||
team_slug: string;
|
||||
}) {
|
||||
const { project, team_slug } = props;
|
||||
return (
|
||||
<div
|
||||
key={project.id}
|
||||
className="relative flex items-center gap-4 rounded-lg border border-border bg-card p-5 transition-colors hover:border-active-border"
|
||||
>
|
||||
{/* TODO - set image */}
|
||||
<ProjectAvatar className="size-10 rounded-full" src="" />
|
||||
|
||||
<div className="flex flex-grow flex-col gap-1">
|
||||
<Link
|
||||
className="group static before:absolute before:top-0 before:right-0 before:bottom-0 before:left-0 before:z-0"
|
||||
// remove /connect when we have overview page
|
||||
href={`/team/${team_slug}/${project.slug}`}
|
||||
>
|
||||
<h2 className="font-medium text-base">{project.name}</h2>
|
||||
</Link>
|
||||
|
||||
<p className="flex items-center gap-1 text-muted-foreground text-sm">
|
||||
<span>{project.monthlyActiveUsers}</span>
|
||||
Monthly Active Users
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button className="z-10 h-auto w-auto p-2" variant="ghost">
|
||||
<EllipsisVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-1">
|
||||
<CopyTextButton
|
||||
textToCopy={project.publishableKey}
|
||||
textToShow="Copy Client ID"
|
||||
copyIconPosition="right"
|
||||
tooltip={undefined}
|
||||
variant="ghost"
|
||||
className="flex h-10 w-full justify-between gap-3 rounded-md px-4 py-2"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/team/${team_slug}/${project.slug}/settings`}>
|
||||
Settings
|
||||
</Link>
|
||||
</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchInput(props: {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
@@ -252,7 +249,7 @@ function SearchInput(props: {
|
||||
placeholder="Search Projects by name or Client ID"
|
||||
value={props.value}
|
||||
onChange={(e) => props.onValueChange(e.target.value)}
|
||||
className="bg-card pl-9"
|
||||
className="bg-background pl-9 lg:w-[400px]"
|
||||
/>
|
||||
<SearchIcon className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
|
||||
</div>
|
||||
@@ -264,28 +261,16 @@ function AddNewButton(props: {
|
||||
teamMembersSettingsPath: string;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="default" className="gap-2">
|
||||
Add New
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[220px] p-2" sideOffset={12}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-between px-2"
|
||||
onClick={props.createProject}
|
||||
>
|
||||
Project
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-between px-2" asChild>
|
||||
<Link href={props.teamMembersSettingsPath}>Team Member</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="default"
|
||||
className="absolute top-0 right-0 gap-2 lg:static"
|
||||
onClick={props.createProject}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
<span>
|
||||
<span className="hidden lg:inline">Create</span> Project
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -307,11 +292,9 @@ function SelectBy(props: {
|
||||
props.onChange(v as SortById);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="min-w-[200px] bg-card capitalize">
|
||||
<SelectTrigger className="min-w-[200px] bg-background capitalize">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="!hidden lg:!inline text-muted-foreground">
|
||||
Sort by
|
||||
</span>
|
||||
<span className="text-muted-foreground">Sort by</span>
|
||||
{valueToLabel[props.value]}
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
|
||||
+28
-17
@@ -66,7 +66,7 @@ async function ChainDistributionCard({ data }: { data: TransactionStats[] }) {
|
||||
async function ContractDistributionCard({
|
||||
data,
|
||||
}: { data: TransactionStats[] }) {
|
||||
const reducedData = await Promise.all(
|
||||
const _reducedData = await Promise.all(
|
||||
Object.entries(
|
||||
data
|
||||
.filter((d) => d.contractAddress)
|
||||
@@ -83,25 +83,36 @@ async function ContractDistributionCard({
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10) // only top ten
|
||||
.map(async ([key, value]) => {
|
||||
const [chainId, contractAddress] = key.split(":");
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineChain(Number(chainId));
|
||||
const chainMeta = await getChainMetadata(chain).catch(() => undefined);
|
||||
const contractData = await fetchDashboardContractMetadata(
|
||||
getContract({
|
||||
chain,
|
||||
address: contractAddress as string, // we filter above
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
return {
|
||||
label: `${contractData?.name} (${chainMeta?.slug || chainId})`,
|
||||
link: `/${chainId}/${contractAddress}`,
|
||||
value,
|
||||
};
|
||||
try {
|
||||
const [chainId, contractAddress] = key.split(":");
|
||||
if (Number(chainId) === 0) {
|
||||
return undefined;
|
||||
}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
const chain = defineChain(Number(chainId));
|
||||
const chainMeta = await getChainMetadata(chain).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const contractData = await fetchDashboardContractMetadata(
|
||||
getContract({
|
||||
chain,
|
||||
address: contractAddress as string, // we filter above
|
||||
client: getThirdwebClient(),
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
return {
|
||||
label: `${contractData?.name} (${chainMeta?.slug || chainId})`,
|
||||
link: `/${chainId}/${contractAddress}`,
|
||||
value,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const reducedData = _reducedData.filter((d) => !!d);
|
||||
|
||||
const aggregateFn = () =>
|
||||
new Set(
|
||||
data
|
||||
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Range } from "components/analytics/date-range-selector";
|
||||
import { Stat } from "components/analytics/stat";
|
||||
import { InAppWalletsSummary } from "components/embedded-wallets/Analytics/Summary";
|
||||
import { AccountAbstractionSummary } from "components/smart-wallets/AccountAbstractionAnalytics/AccountAbstractionSummary";
|
||||
import { differenceInDays } from "date-fns";
|
||||
import { ArrowRightIcon, CableIcon, WalletCardsIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import type {
|
||||
InAppWalletStats,
|
||||
UserOpStats,
|
||||
WalletStats,
|
||||
} from "types/analytics";
|
||||
import { DateRangeSelector } from "../../../../../../components/analytics/date-range-selector";
|
||||
import { IntervalSelector } from "../../../../../../components/analytics/interval-selector";
|
||||
import { DailyConnectionsChartCard } from "./_components/DailyConnectionsChartCard";
|
||||
import { WalletConnectorsChartCard } from "./_components/WalletConnectorsChartCard";
|
||||
import { WalletDistributionChartCard } from "./_components/WalletDistributionChartCard";
|
||||
|
||||
export function ConnectAnalyticsDashboardUI(props: {
|
||||
walletUsage: WalletStats[];
|
||||
aggregateWalletUsage: WalletStats[];
|
||||
aggregateUserOpUsageQuery?: UserOpStats;
|
||||
connectLayoutSlug: string;
|
||||
isPending: boolean;
|
||||
inAppAggregateQuery?: {
|
||||
allTimeStats: InAppWalletStats[];
|
||||
monthlyStats: InAppWalletStats[];
|
||||
};
|
||||
setRange: (range: Range) => void;
|
||||
range: Range;
|
||||
intervalType: "day" | "week";
|
||||
setIntervalType: (intervalType: "day" | "week") => void;
|
||||
}) {
|
||||
const { totalWallets, uniqueWallets } = useMemo(() => {
|
||||
return props.aggregateWalletUsage.reduce(
|
||||
(acc, curr) => {
|
||||
acc.totalWallets += curr.totalConnections;
|
||||
acc.uniqueWallets += curr.uniqueWalletsConnected;
|
||||
return acc;
|
||||
},
|
||||
{ uniqueWallets: 0, totalWallets: 0 },
|
||||
);
|
||||
}, [props.aggregateWalletUsage]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 lg:gap-6">
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
|
||||
Connections
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
View how many users are connecting to your app.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 lg:gap-6">
|
||||
<Stat label="Connections" value={totalWallets} icon={CableIcon} />
|
||||
<Stat
|
||||
label="Unique Wallets"
|
||||
value={uniqueWallets}
|
||||
icon={WalletCardsIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-4">
|
||||
<div className="w-full border-separate border-b" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
|
||||
In-App Wallets
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
View metrics for your in-app wallets.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button asChild variant="primary" size="sm" className="w-full">
|
||||
<Link href={`${props.connectLayoutSlug}/in-app-wallets`}>
|
||||
In-App Wallet Metrics
|
||||
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<InAppWalletsSummary
|
||||
allTimeStats={props.inAppAggregateQuery?.allTimeStats}
|
||||
monthlyStats={props.inAppAggregateQuery?.monthlyStats}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4">
|
||||
<div className="w-full border-separate border-b" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
|
||||
Account Abstraction
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
View how smart wallets are used in your app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button asChild variant="primary" size="sm" className="w-full">
|
||||
<Link href={`${props.connectLayoutSlug}/account-abstraction`}>
|
||||
Account Abstraction Metrics
|
||||
<ArrowRightIcon className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AccountAbstractionSummary
|
||||
aggregateUserOpUsageQuery={props.aggregateUserOpUsageQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4">
|
||||
<div className="w-full border-separate border-b" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<DateRangeSelector
|
||||
range={props.range}
|
||||
setRange={(newRange) => {
|
||||
props.setRange(newRange);
|
||||
const days = differenceInDays(newRange.to, newRange.from);
|
||||
props.setIntervalType(days > 30 ? "week" : "day");
|
||||
}}
|
||||
/>
|
||||
<IntervalSelector
|
||||
intervalType={props.intervalType}
|
||||
setIntervalType={props.setIntervalType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={props.walletUsage}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={props.walletUsage}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
|
||||
<WalletDistributionChartCard
|
||||
walletStats={props.walletUsage}
|
||||
isPending={props.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { createUserOpStatsStub } from "components/smart-wallets/AccountAbstractionAnalytics/storyUtils";
|
||||
import { getLastNDaysRange } from "../../../../../../../components/analytics/date-range-selector";
|
||||
import { mobileViewport } from "../../../../../../../stories/utils";
|
||||
import { ConnectAnalyticsDashboardUI } from "../ConnectAnalyticsDashboardUI";
|
||||
import { createWalletStatsStub } from "./storyUtils";
|
||||
|
||||
const meta = {
|
||||
title: "Charts/Connect/Analytics Dashboard",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="container max-w-[1150px] py-8">
|
||||
<ConnectAnalyticsDashboardUI
|
||||
setRange={() => {}}
|
||||
range={getLastNDaysRange("last-120")}
|
||||
intervalType="day"
|
||||
setIntervalType={() => {}}
|
||||
walletUsage={createWalletStatsStub(30)}
|
||||
aggregateWalletUsage={createWalletStatsStub(30)}
|
||||
aggregateUserOpUsageQuery={createUserOpStatsStub(1)?.[0]}
|
||||
connectLayoutSlug="connectLayoutSlug"
|
||||
isPending={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
BadgeContainer,
|
||||
mobileViewport,
|
||||
} from "../../../../../../../stories/utils";
|
||||
import { DailyConnectionsChartCard } from "./DailyConnectionsChartCard";
|
||||
import { createWalletStatsStub } from "./storyUtils";
|
||||
|
||||
const meta = {
|
||||
title: "Charts/Connect/Daily Connections",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="container flex max-w-[1150px] flex-col gap-6 py-8">
|
||||
<BadgeContainer label="30 days">
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={createWalletStatsStub(30)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="60 days">
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={createWalletStatsStub(60)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 days">
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={createWalletStatsStub(10)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 days">
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Loading">
|
||||
<DailyConnectionsChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isPending={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-243
@@ -1,243 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { DotNetIcon } from "components/icons/brand-icons/DotNetIcon";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { UnityIcon } from "components/icons/brand-icons/UnityIcon";
|
||||
import { UnrealIcon } from "components/icons/brand-icons/UnrealIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
LabelList,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type { WalletStats } from "types/analytics";
|
||||
import { formatTickerNumber } from "../../../../../../../lib/format-utils";
|
||||
|
||||
type ChartToShow = "uniqueWallets" | "totalWallets";
|
||||
|
||||
type ChartData = {
|
||||
time: string; // human readable date
|
||||
totalWallets: number;
|
||||
uniqueWallets: number;
|
||||
};
|
||||
|
||||
const chartConfig = {
|
||||
uniqueWallets: {
|
||||
label: "Unique Wallets",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
totalWallets: {
|
||||
label: "Total Wallets",
|
||||
color: "hsl(var(--chart-1))",
|
||||
},
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const chartLabelToShow: Record<ChartToShow, string> = {
|
||||
uniqueWallets: "Unique Wallets",
|
||||
totalWallets: "Total Wallets",
|
||||
};
|
||||
|
||||
export function DailyConnectionsChartCard(props: {
|
||||
walletStats: WalletStats[];
|
||||
isPending: boolean;
|
||||
}) {
|
||||
const { walletStats } = props;
|
||||
|
||||
const [chartToShow, setChartToShow] = useState<ChartToShow>("uniqueWallets");
|
||||
const chartToShowOptions: ChartToShow[] = ["uniqueWallets", "totalWallets"];
|
||||
|
||||
const barChartData: ChartData[] = useMemo(() => {
|
||||
const chartDataMap: Map<string, ChartData> = new Map();
|
||||
|
||||
for (const data of walletStats) {
|
||||
const chartData = chartDataMap.get(data.date);
|
||||
if (!chartData) {
|
||||
chartDataMap.set(data.date, {
|
||||
time: format(new Date(data.date), "MMM dd"),
|
||||
totalWallets: data.totalConnections,
|
||||
uniqueWallets: data.uniqueWalletsConnected,
|
||||
});
|
||||
} else {
|
||||
chartData.totalWallets += data.totalConnections;
|
||||
chartData.uniqueWallets += data.uniqueWalletsConnected;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(chartDataMap.values());
|
||||
}, [walletStats]);
|
||||
|
||||
const disableActions = props.isPending || barChartData.length === 0;
|
||||
|
||||
return (
|
||||
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
|
||||
Daily Connections
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
Total and unique wallets addresses that connected to your app each day.
|
||||
</p>
|
||||
|
||||
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
{/* Selector */}
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
setChartToShow(v as ChartToShow);
|
||||
}}
|
||||
value={chartToShow}
|
||||
disabled={disableActions}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{chartToShowOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{chartLabelToShow[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ExportToCSVButton
|
||||
disabled={disableActions}
|
||||
className="bg-background"
|
||||
getData={async () => {
|
||||
const header = ["Date", "Total Wallets", "Unique Wallets"];
|
||||
const rows = barChartData.map((row) => [
|
||||
row.time,
|
||||
row.totalWallets.toString(),
|
||||
row.uniqueWallets.toString(),
|
||||
]);
|
||||
return { header, rows };
|
||||
}}
|
||||
fileName="DailyConnections"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[250px] w-full md:h-[350px]"
|
||||
>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : barChartData.length === 0 ? (
|
||||
<EmptyChartState>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">
|
||||
Send your first connect event
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/getting-started"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react/v5"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/react-native/v5"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/dotnet/getting-started"
|
||||
label="Unity"
|
||||
icon={UnityIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/unreal-engine/getting-started"
|
||||
label="Unreal Engine"
|
||||
icon={UnrealIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/dotnet/getting-started"
|
||||
label=".NET"
|
||||
icon={DotNetIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={barChartData}
|
||||
margin={{
|
||||
top: 20,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
dataKey={(data) => data[chartToShow]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatTickerNumber(value)}
|
||||
/>
|
||||
|
||||
<ChartTooltip
|
||||
cursor={true}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
valueFormatter={(value) => formatTickerNumber(Number(value))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Bar
|
||||
dataKey={chartToShow}
|
||||
fill={`var(--color-${chartToShow})`}
|
||||
radius={8}
|
||||
strokeWidth={1}
|
||||
className="stroke-background"
|
||||
>
|
||||
{barChartData.length < 50 && (
|
||||
<LabelList
|
||||
position="top"
|
||||
offset={12}
|
||||
className="invisible fill-foreground sm:visible"
|
||||
fontSize={12}
|
||||
formatter={(value: number) => formatTickerNumber(value)}
|
||||
/>
|
||||
)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
BadgeContainer,
|
||||
mobileViewport,
|
||||
} from "../../../../../../../stories/utils";
|
||||
import { WalletConnectorsChartCard } from "./WalletConnectorsChartCard";
|
||||
import { createWalletStatsStub } from "./storyUtils";
|
||||
|
||||
const meta = {
|
||||
title: "Charts/Connect/Wallet Connectors",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<BadgeContainer label="30 days">
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={createWalletStatsStub(30)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="60 days">
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={createWalletStatsStub(60)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 days">
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={createWalletStatsStub(10)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 days">
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Loading">
|
||||
<WalletConnectorsChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isPending={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-260
@@ -1,260 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { format } from "date-fns";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
|
||||
import type { WalletStats } from "types/analytics";
|
||||
import {
|
||||
formatTickerNumber,
|
||||
formatWalletType,
|
||||
} from "../../../../../../../lib/format-utils";
|
||||
|
||||
type ChartToShow = "uniqueWalletsConnected" | "totalConnections";
|
||||
|
||||
type ChartData = Record<string, number> & {
|
||||
time: string; // human readable date
|
||||
};
|
||||
|
||||
const chartLabelToShow: Record<ChartToShow, string> = {
|
||||
uniqueWalletsConnected: "Unique Wallets",
|
||||
totalConnections: "Total Wallets",
|
||||
};
|
||||
export function WalletConnectorsChartCard(props: {
|
||||
walletStats: WalletStats[];
|
||||
isPending: boolean;
|
||||
}) {
|
||||
// show top 10 wallets as distinct, and combine the rest as "Others"
|
||||
const topWalletsToShow = 10;
|
||||
const { walletStats } = props;
|
||||
const [chartToShow, setChartToShow] = useState<ChartToShow>(
|
||||
"uniqueWalletsConnected",
|
||||
);
|
||||
const chartToShowOptions: ChartToShow[] = [
|
||||
"uniqueWalletsConnected",
|
||||
"totalConnections",
|
||||
];
|
||||
|
||||
const { chartConfig, chartData } = useMemo(() => {
|
||||
const _chartConfig: ChartConfig = {};
|
||||
const _chartDataMap: Map<string, ChartData> = new Map();
|
||||
const walletTypeToValueMap: Map<string, number> = new Map();
|
||||
// for each stat, add it in _chartDataMap
|
||||
for (const stat of walletStats) {
|
||||
const chartData = _chartDataMap.get(stat.date);
|
||||
const { walletType: rawWalletType } = stat;
|
||||
const walletType = formatWalletType(rawWalletType);
|
||||
|
||||
// if no data for current day - create new entry
|
||||
if (!chartData) {
|
||||
_chartDataMap.set(stat.date, {
|
||||
time: format(new Date(stat.date), "MMM dd"),
|
||||
[walletType]: stat[chartToShow],
|
||||
} as ChartData);
|
||||
} else {
|
||||
chartData[walletType] =
|
||||
(chartData[walletType] || 0) + stat[chartToShow];
|
||||
}
|
||||
|
||||
walletTypeToValueMap.set(
|
||||
walletType,
|
||||
stat[chartToShow] + (walletTypeToValueMap.get(walletType) || 0),
|
||||
);
|
||||
}
|
||||
|
||||
const walletTypesSorted = Array.from(walletTypeToValueMap.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((w) => w[0]);
|
||||
|
||||
const walletTypesToShow = walletTypesSorted.slice(0, topWalletsToShow);
|
||||
const walletTypesToTagAsOthers = walletTypesSorted.slice(topWalletsToShow);
|
||||
|
||||
// replace walletTypesToTagAsOthers walletType with "other"
|
||||
for (const data of _chartDataMap.values()) {
|
||||
for (const walletType in data) {
|
||||
if (walletTypesToTagAsOthers.includes(walletType)) {
|
||||
data.others = (data.others || 0) + (data[walletType] || 0);
|
||||
delete data[walletType];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walletTypesToShow.forEach((walletType, i) => {
|
||||
_chartConfig[walletType] = {
|
||||
label: walletTypesToShow[i],
|
||||
color: `hsl(var(--chart-${(i % 10) + 1}))`,
|
||||
};
|
||||
});
|
||||
|
||||
// Add Other
|
||||
walletTypesToShow.push("others");
|
||||
_chartConfig.others = {
|
||||
label: "Others",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
};
|
||||
|
||||
return {
|
||||
chartData: Array.from(_chartDataMap.values()),
|
||||
chartConfig: _chartConfig,
|
||||
};
|
||||
}, [walletStats, chartToShow]);
|
||||
|
||||
const uniqueWalletTypes = Object.keys(chartConfig);
|
||||
const disableActions = props.isPending || chartData.length === 0;
|
||||
|
||||
return (
|
||||
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
|
||||
Connected Wallets
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
The different types of wallets used to connect to your app each day.
|
||||
</p>
|
||||
|
||||
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
setChartToShow(v as ChartToShow);
|
||||
}}
|
||||
value={chartToShow}
|
||||
disabled={disableActions}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{chartToShowOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{chartLabelToShow[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ExportToCSVButton
|
||||
className="bg-background"
|
||||
fileName="Connect Wallets"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
// Shows the number of each type of wallet connected on all dates
|
||||
const header = ["Date", ...uniqueWalletTypes];
|
||||
const rows = chartData.map((data) => {
|
||||
const { time, ...rest } = data;
|
||||
return [
|
||||
time,
|
||||
...uniqueWalletTypes.map((w) => (rest[w] || 0).toString()),
|
||||
];
|
||||
});
|
||||
return { header, rows };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer config={chartConfig} className="h-[400px] w-full">
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ? (
|
||||
<EmptyChartState>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">
|
||||
Connect any wallet to your app
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<BarChart
|
||||
accessibilityLayer
|
||||
data={chartData}
|
||||
margin={{
|
||||
top: 20,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
dataKey={(data) =>
|
||||
Object.entries(data)
|
||||
.filter(([key]) => key !== "time")
|
||||
.map(([, value]) => value)
|
||||
.reduce((acc, current) => Number(acc) + Number(current), 0)
|
||||
}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => formatTickerNumber(value)}
|
||||
/>
|
||||
|
||||
<ChartTooltip
|
||||
cursor={true}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
valueFormatter={(value) => formatTickerNumber(Number(value))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
{uniqueWalletTypes.map((walletType) => {
|
||||
return (
|
||||
<Bar
|
||||
key={walletType}
|
||||
dataKey={walletType}
|
||||
fill={chartConfig[walletType]?.color}
|
||||
radius={4}
|
||||
stackId="a"
|
||||
strokeWidth={1}
|
||||
className="stroke-background"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</BarChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
BadgeContainer,
|
||||
mobileViewport,
|
||||
} from "../../../../../../../stories/utils";
|
||||
import { WalletDistributionChartCard } from "./WalletDistributionChartCard";
|
||||
import { createWalletStatsStub } from "./storyUtils";
|
||||
|
||||
const meta = {
|
||||
title: "Charts/Connect/Wallet Distribution",
|
||||
component: Component,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
} satisfies Meta<typeof Component>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Desktop: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const Mobile: Story = {
|
||||
args: {},
|
||||
parameters: {
|
||||
viewport: mobileViewport("iphone14"),
|
||||
},
|
||||
};
|
||||
|
||||
function Component() {
|
||||
return (
|
||||
<div className="container flex max-w-[1150px] flex-col gap-10 py-10">
|
||||
<BadgeContainer label="30 days">
|
||||
<WalletDistributionChartCard
|
||||
walletStats={createWalletStatsStub(30)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="60 days">
|
||||
<WalletDistributionChartCard
|
||||
walletStats={createWalletStatsStub(60)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="10 days">
|
||||
<WalletDistributionChartCard
|
||||
walletStats={createWalletStatsStub(10)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="0 days">
|
||||
<WalletDistributionChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isPending={false}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
|
||||
<BadgeContainer label="Loading">
|
||||
<WalletDistributionChartCard
|
||||
walletStats={createWalletStatsStub(0)}
|
||||
isPending={true}
|
||||
/>
|
||||
</BadgeContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-263
@@ -1,263 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ExportToCSVButton } from "@/components/blocks/ExportToCSVButton";
|
||||
import {
|
||||
type ChartConfig,
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
EmptyChartState,
|
||||
LoadingChartState,
|
||||
} from "components/analytics/empty-chart-state";
|
||||
import { ReactIcon } from "components/icons/brand-icons/ReactIcon";
|
||||
import { TypeScriptIcon } from "components/icons/brand-icons/TypeScriptIcon";
|
||||
import { DocLink } from "components/shared/DocLink";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Pie, PieChart } from "recharts";
|
||||
import type { WalletStats } from "types/analytics";
|
||||
import {
|
||||
formatTickerNumber,
|
||||
formatWalletType,
|
||||
} from "../../../../../../../lib/format-utils";
|
||||
|
||||
type ChartToShow = "totalConnections" | "uniqueWalletsConnected";
|
||||
|
||||
type ChartData = {
|
||||
walletType: string;
|
||||
value: number;
|
||||
fill: string;
|
||||
};
|
||||
|
||||
const chartLabelToShow: Record<ChartToShow, string> = {
|
||||
uniqueWalletsConnected: "Unique Wallets",
|
||||
totalConnections: "Total Wallets",
|
||||
};
|
||||
|
||||
export function WalletDistributionChartCard(props: {
|
||||
walletStats: WalletStats[];
|
||||
isPending: boolean;
|
||||
}) {
|
||||
// show these many top wallets as distinct, and combine the rest as "Others"
|
||||
const topWalletsToShow = 10;
|
||||
const { walletStats } = props;
|
||||
const [chartToShow, setChartToShow] =
|
||||
useState<ChartToShow>("totalConnections");
|
||||
const chartToShowOptions: ChartToShow[] = [
|
||||
"totalConnections",
|
||||
"uniqueWalletsConnected",
|
||||
];
|
||||
|
||||
const { chartConfig, chartData, totalConnections, uniqueConnections } =
|
||||
useMemo(() => {
|
||||
const _chartDataMap: Map<string, number> = new Map();
|
||||
const walletTypeToValueMap: Map<string, number> = new Map();
|
||||
|
||||
let _totalConnections = 0;
|
||||
let _uniqueConnections = 0;
|
||||
|
||||
for (const stat of walletStats) {
|
||||
const { walletType: rawWalletType } = stat;
|
||||
const walletType = formatWalletType(rawWalletType);
|
||||
const chartData = _chartDataMap.get(walletType);
|
||||
|
||||
_totalConnections += stat.totalConnections;
|
||||
_uniqueConnections += stat.uniqueWalletsConnected;
|
||||
_chartDataMap.set(walletType, (chartData || 0) + stat[chartToShow]);
|
||||
walletTypeToValueMap.set(
|
||||
walletType,
|
||||
(walletTypeToValueMap.get(walletType) || 0) + stat[chartToShow],
|
||||
);
|
||||
}
|
||||
|
||||
const walletTypesSortedByValue = Array.from(
|
||||
walletTypeToValueMap.entries(),
|
||||
)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map((w) => w[0]);
|
||||
|
||||
const walletTypesToShow = walletTypesSortedByValue.slice(
|
||||
0,
|
||||
topWalletsToShow,
|
||||
);
|
||||
|
||||
const walletTypesToTagAsOthers =
|
||||
walletTypesSortedByValue.slice(topWalletsToShow);
|
||||
|
||||
for (const walletType of walletTypesToTagAsOthers) {
|
||||
const val = _chartDataMap.get(walletType);
|
||||
if (val) {
|
||||
const othersVal = _chartDataMap.get("others");
|
||||
_chartDataMap.set("others", othersVal ? othersVal + val : val);
|
||||
}
|
||||
|
||||
_chartDataMap.delete(walletType);
|
||||
}
|
||||
|
||||
const _chartConfig: ChartConfig = {};
|
||||
walletTypesToShow.forEach((walletType, i) => {
|
||||
_chartConfig[walletType] = {
|
||||
label: walletTypesToShow[i],
|
||||
color: `hsl(var(--chart-${(i % 10) + 1}))`,
|
||||
};
|
||||
});
|
||||
|
||||
// Add Others
|
||||
_chartConfig.others = {
|
||||
label: "Others",
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
};
|
||||
|
||||
const _chartData: ChartData[] = Array.from(_chartDataMap).map(
|
||||
([walletType, data]) => {
|
||||
return {
|
||||
walletType,
|
||||
value: data,
|
||||
fill: _chartConfig[walletType]?.color || "transparent",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// sort the data
|
||||
_chartData.sort((a, b) => b.value - a.value);
|
||||
|
||||
return {
|
||||
chartData: _chartData,
|
||||
chartConfig: _chartConfig,
|
||||
totalConnections: _totalConnections,
|
||||
uniqueConnections: _uniqueConnections,
|
||||
};
|
||||
}, [walletStats, chartToShow]);
|
||||
|
||||
const disableActions = props.isPending || chartData.length === 0;
|
||||
return (
|
||||
<div className="relative w-full rounded-lg border border-border bg-card p-4 md:p-6">
|
||||
<h3 className="mb-1 font-semibold text-xl tracking-tight md:text-2xl">
|
||||
Wallet Distribution
|
||||
</h3>
|
||||
<p className="mb-3 text-muted-foreground text-sm">
|
||||
Distribution of wallet types used to connect to your app.
|
||||
</p>
|
||||
|
||||
{/* Selector */}
|
||||
<div className="top-6 right-6 mb-4 grid grid-cols-2 items-center gap-2 md:absolute md:mb-0 md:flex">
|
||||
<Select
|
||||
onValueChange={(v) => {
|
||||
setChartToShow(v as ChartToShow);
|
||||
}}
|
||||
value={chartToShow}
|
||||
disabled={props.isPending || chartData.length === 0}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{chartToShowOptions.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{chartLabelToShow[option]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<ExportToCSVButton
|
||||
fileName="Wallet Distribution"
|
||||
className="bg-background"
|
||||
disabled={disableActions}
|
||||
getData={async () => {
|
||||
const header = [
|
||||
"Wallet",
|
||||
`${chartToShow === "totalConnections" ? "Total" : "Unique"} Connections`,
|
||||
`Percentage of ${chartToShow === "totalConnections" ? "Total" : "Unique"} Connections`,
|
||||
];
|
||||
const rows = chartData.map((d) => {
|
||||
return [
|
||||
d.walletType,
|
||||
d.value.toString(),
|
||||
`${((d.value / (chartToShow === "totalConnections" ? totalConnections : uniqueConnections)) * 100).toFixed(2)}%`,
|
||||
];
|
||||
});
|
||||
return {
|
||||
header,
|
||||
rows,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chart */}
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="mt-6 h-[500px] w-full [&_.recharts-pie-label-text]:fill-foreground"
|
||||
>
|
||||
{props.isPending ? (
|
||||
<LoadingChartState />
|
||||
) : chartData.length === 0 ? (
|
||||
<EmptyChartState>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<span className="mb-6 text-lg">
|
||||
Connect any wallet to your app
|
||||
</span>
|
||||
<div className="flex max-w-md flex-wrap items-center justify-center gap-x-6 gap-y-4">
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="TypeScript"
|
||||
icon={TypeScriptIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="React"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
<DocLink
|
||||
link="https://portal.thirdweb.com/typescript/v5/supported-wallets"
|
||||
label="React Native"
|
||||
icon={ReactIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</EmptyChartState>
|
||||
) : (
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
cursor={false}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
valueFormatter={(v) => {
|
||||
if (typeof v === "number") {
|
||||
const sumValue =
|
||||
chartToShow === "uniqueWalletsConnected"
|
||||
? uniqueConnections
|
||||
: totalConnections;
|
||||
const percentageValue = ((v / sumValue) * 100).toFixed(2);
|
||||
return `${percentageValue}% - ${formatTickerNumber(v)}`;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} className="" />
|
||||
<Pie
|
||||
data={chartData}
|
||||
dataKey="value"
|
||||
nameKey="walletType"
|
||||
innerRadius={60}
|
||||
strokeWidth={2}
|
||||
stroke="hsl(var(--muted))"
|
||||
/>
|
||||
</PieChart>
|
||||
)}
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
import type { WalletId } from "thirdweb/wallets";
|
||||
import type { WalletStats } from "types/analytics";
|
||||
|
||||
const walletsToPickFrom: WalletId[] = [
|
||||
"io.metamask",
|
||||
"com.binance",
|
||||
"com.coinbase.wallet",
|
||||
"io.zerion.wallet",
|
||||
"me.rainbow",
|
||||
"com.trustwallet.app",
|
||||
"com.zengo",
|
||||
"xyz.argent",
|
||||
"com.exodus",
|
||||
"app.phantom",
|
||||
"com.okex.wallet",
|
||||
"io.clingon",
|
||||
"com.broearn",
|
||||
"com.coinomi",
|
||||
"com.ripio",
|
||||
"com.sabay.wallet",
|
||||
"io.tokoin",
|
||||
"world.fncy",
|
||||
"io.copiosa",
|
||||
];
|
||||
|
||||
const pickRandomWallet = () => {
|
||||
return (
|
||||
walletsToPickFrom[Math.floor(Math.random() * walletsToPickFrom.length)] ||
|
||||
"io.metamask"
|
||||
);
|
||||
};
|
||||
|
||||
export function createWalletStatsStub(days: number): WalletStats[] {
|
||||
const stubbedData: WalletStats[] = [];
|
||||
|
||||
let d = days;
|
||||
while (d !== 0) {
|
||||
const uniqueWallets = Math.floor(Math.random() * 100);
|
||||
stubbedData.push({
|
||||
date: new Date(2024, 1, d).toLocaleString(),
|
||||
totalConnections: uniqueWallets + Math.floor(Math.random() * 100),
|
||||
uniqueWalletsConnected: uniqueWallets,
|
||||
walletType: pickRandomWallet(),
|
||||
});
|
||||
|
||||
if (Math.random() > 0.7) {
|
||||
d--;
|
||||
}
|
||||
}
|
||||
|
||||
return stubbedData;
|
||||
}
|
||||
+1
-1
@@ -238,7 +238,7 @@ function CreateWebhookButton(props: PropsWithChildren<PayWebhooksPageProps>) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{props.children}</DialogTrigger>
|
||||
<DialogContent className="z-[10001]" dialogOverlayClassName="z-[10000]">
|
||||
<DialogContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
|
||||
+24
-6
@@ -1,3 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FileCode2Icon, MessageSquareQuoteIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ResponsiveSearchParamsProvider,
|
||||
ResponsiveSuspense,
|
||||
@@ -20,17 +23,32 @@ export function NebulaAnalyticsPage(props: {
|
||||
return (
|
||||
<ResponsiveSearchParamsProvider value={props.searchParams}>
|
||||
<header className="border-b">
|
||||
<div className="container flex flex-col items-start gap-3 py-10 md:flex-row md:items-center">
|
||||
<div className="flex-1">
|
||||
<h1 className="font-semibold text-2xl tracking-tight md:text-3xl">
|
||||
Nebula
|
||||
</h1>
|
||||
<div className="container flex flex-col items-start gap-4 py-10 md:flex-row md:items-center md:justify-between">
|
||||
<h1 className="font-semibold text-3xl tracking-tight">Nebula</h1>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" className="gap-2 bg-card" asChild>
|
||||
<Link href="https://nebula.thirdweb.com" target="_blank">
|
||||
<MessageSquareQuoteIcon className="size-4 text-muted-foreground" />
|
||||
Playground
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" className="gap-2 bg-card" asChild>
|
||||
<Link href="https://portal.thirdweb.com/nebula" target="_blank">
|
||||
<FileCode2Icon className="size-4 text-muted-foreground" />
|
||||
API Reference
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<NebulaAnalyticsFilter />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="container pt-8 pb-20">
|
||||
<div className="mb-4 flex flex-col justify-between gap-3 md:flex-row md:items-end">
|
||||
<h2 className="font-semibold text-2xl tracking-tight">Analytics</h2>
|
||||
<NebulaAnalyticsFilter />
|
||||
</div>
|
||||
<ResponsiveSuspense
|
||||
searchParamsUsed={["from", "to", "interval"]}
|
||||
fallback={<NebulaAnalyticsDashboardUI data={[]} isPending={true} />}
|
||||
|
||||
+6
-3
@@ -36,6 +36,10 @@ function Story(props: {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-4 py-6">
|
||||
<ProjectGeneralSettingsPageUI
|
||||
updateProjectImage={async (file) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
console.log("updateProjectImage", file);
|
||||
}}
|
||||
isOwnerAccount={props.isOwnerAccount}
|
||||
transferProject={async (newTeam) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
@@ -72,9 +76,8 @@ function Story(props: {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return {
|
||||
data: {
|
||||
secret: new Array(86).fill("x").join(""),
|
||||
secretHash: new Array(64).fill("x").join(""),
|
||||
secretMasked: "123...4567",
|
||||
secret: `sk_${new Array(86).fill("x").join("")}`,
|
||||
secretMasked: "sk_123...4567",
|
||||
},
|
||||
};
|
||||
}}
|
||||
|
||||
+94
-1
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
import { apiServerProxy } from "@/actions/proxies";
|
||||
import type { Project } from "@/api/projects";
|
||||
import type { Team } from "@/api/team";
|
||||
import { GradientAvatar } from "@/components/blocks/Avatars/GradientAvatar";
|
||||
@@ -31,6 +32,7 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { ToolTipLabel } from "@/components/ui/tooltip";
|
||||
import { useDashboardRouter } from "@/lib/DashboardRouter";
|
||||
import { resolveSchemeWithErrorHandler } from "@/lib/resolveSchemeWithErrorHandler";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RotateSecretKeyAPIReturnType } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import {
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
type ServiceName,
|
||||
getServiceByName,
|
||||
} from "@thirdweb-dev/service-utils";
|
||||
import { FileInput } from "components/shared/FileInput";
|
||||
import { format } from "date-fns";
|
||||
import { useTrack } from "hooks/analytics/useTrack";
|
||||
import {
|
||||
@@ -60,11 +63,11 @@ import { type UseFormReturn, useForm } from "react-hook-form";
|
||||
import { type FieldArrayWithId, useFieldArray } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { upload } from "thirdweb/storage";
|
||||
import { RE_BUNDLE_ID } from "utils/regex";
|
||||
import { joinWithComma, toArrFromList } from "utils/string";
|
||||
import { validStrList } from "utils/validations";
|
||||
import { z } from "zod";
|
||||
import { apiServerProxy } from "../../../../../@/actions/proxies";
|
||||
import {
|
||||
HIDDEN_SERVICES,
|
||||
projectDomainsSchema,
|
||||
@@ -119,6 +122,29 @@ export function ProjectGeneralSettingsPage(props: {
|
||||
client={props.client}
|
||||
teamSlug={props.teamSlug}
|
||||
project={props.project}
|
||||
updateProjectImage={async (file) => {
|
||||
let uri: string | undefined = undefined;
|
||||
|
||||
if (file) {
|
||||
// upload to IPFS
|
||||
uri = await upload({
|
||||
client: props.client,
|
||||
files: [file],
|
||||
});
|
||||
}
|
||||
|
||||
await updateProjectClient(
|
||||
{
|
||||
projectId: props.project.id,
|
||||
teamId: props.project.teamId,
|
||||
},
|
||||
{
|
||||
image: uri,
|
||||
},
|
||||
);
|
||||
|
||||
router.refresh();
|
||||
}}
|
||||
updateProject={async (projectValues) => {
|
||||
return updateProjectClient(
|
||||
{
|
||||
@@ -184,6 +210,7 @@ export function ProjectGeneralSettingsPageUI(props: {
|
||||
client: ThirdwebClient;
|
||||
transferProject: (newTeam: Team) => Promise<void>;
|
||||
isOwnerAccount: boolean;
|
||||
updateProjectImage: (file: File | undefined) => Promise<void>;
|
||||
}) {
|
||||
const projectLayout = `/team/${props.teamSlug}/${props.project.slug}`;
|
||||
|
||||
@@ -320,6 +347,12 @@ export function ProjectGeneralSettingsPageUI(props: {
|
||||
handleSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
<ProjectImageSetting
|
||||
updateProjectImage={props.updateProjectImage}
|
||||
avatar={project.image || null}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
<ProjectKeyDetails
|
||||
project={project}
|
||||
rotateSecretKey={props.rotateSecretKey}
|
||||
@@ -402,6 +435,66 @@ function ProjectNameSetting(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectImageSetting(props: {
|
||||
updateProjectImage: (file: File | undefined) => Promise<void>;
|
||||
avatar: string | null;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const projectAvatarUrl = resolveSchemeWithErrorHandler({
|
||||
client: props.client,
|
||||
uri: props.avatar || undefined,
|
||||
});
|
||||
|
||||
const [projectAvatar, setProjectAvatar] = useState<File | undefined>();
|
||||
|
||||
const updateProjectAvatarMutation = useMutation({
|
||||
mutationFn: async (_avatar: File | undefined) => {
|
||||
await props.updateProjectImage(_avatar);
|
||||
},
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
const promise = updateProjectAvatarMutation.mutateAsync(projectAvatar);
|
||||
toast.promise(promise, {
|
||||
success: "Project avatar updated successfully",
|
||||
error: "Failed to update project avatar",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsCard
|
||||
bottomText="An avatar is optional but strongly recommended."
|
||||
saveButton={{
|
||||
onClick: handleSave,
|
||||
disabled: false,
|
||||
isPending: updateProjectAvatarMutation.isPending,
|
||||
}}
|
||||
noPermissionText={undefined}
|
||||
errorText={undefined}
|
||||
>
|
||||
<div className="flex flex-row gap-4 md:justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-xl tracking-tight">
|
||||
Project Avatar
|
||||
</h3>
|
||||
<p className="mt-1.5 mb-4 text-foreground text-sm leading-relaxed">
|
||||
This is your project's avatar. <br /> Click on the avatar to upload
|
||||
a custom one
|
||||
</p>
|
||||
</div>
|
||||
<FileInput
|
||||
accept={{ "image/*": [] }}
|
||||
value={projectAvatar}
|
||||
setValue={setProjectAvatar}
|
||||
className="w-20 rounded-full lg:w-28"
|
||||
disableHelperText
|
||||
fileUrl={projectAvatarUrl}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
);
|
||||
}
|
||||
|
||||
function AllowedDomainsSetting(props: {
|
||||
form: UpdateAPIForm;
|
||||
isUpdatingProject: boolean;
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
NotificationButtonInner,
|
||||
type NotificationMetadata,
|
||||
} from "./NotificationButton";
|
||||
|
||||
const meta = {
|
||||
title: "headers/components/NotificationButton",
|
||||
component: NotificationButtonInner,
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="flex justify-end px-6 py-4">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof NotificationButtonInner>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const titlesToPickFrom = [
|
||||
"Insight API Supports Block Queries",
|
||||
"Insight Adds Search Capability",
|
||||
"RPC Edge: Faster, Leaner, and Smarter",
|
||||
".NET/Unity - Insight Indexer and Engine Wallet Integration",
|
||||
"Invite and Manage Team Members on Dashboard",
|
||||
"Insight API ENS Support",
|
||||
"Insight Token Queries Just Got 10X Faster",
|
||||
"Insight indexer major version upgrade",
|
||||
"Introducing Universal Bridge",
|
||||
"Unreal Engine Plugin v2",
|
||||
"Cross-chain deterministic contract deployments",
|
||||
"Organize Contracts into Projects on Dashboard",
|
||||
".NET/Unity - Auth token login, timeout improvements, rotating secret keys.",
|
||||
"Mint fees for contract deployments update",
|
||||
"Engine v2.1.32: Circle Wallet and Secure Credential Management",
|
||||
"Nebula Update v0.0.7: Support for swapping and bridging, Offchain",
|
||||
"Insight - Automatic Event & Transaction Resolution",
|
||||
"Nebula Update v0.0.6: Upgraded Model, Speed & Accuracy Improvements",
|
||||
"Enhancing RPC Edge Support with Support IDs",
|
||||
"Blocks data retrieval with Insight [v0.2.2-beta]",
|
||||
"Insight reorg handling improvement",
|
||||
"Resilient Infrastructure & Smarter Insights",
|
||||
"Nebula Update v0.0.5: Advanced configurations, new endpoints, and simplified context filters",
|
||||
"Universal Smart Wallet Addresses",
|
||||
".NET/Unity - Nebula Integration for native apps and games!",
|
||||
];
|
||||
|
||||
function generateRandomNotification(isRead: boolean): NotificationMetadata {
|
||||
const index = Math.floor(Math.random() * titlesToPickFrom.length);
|
||||
const safeIndex = Math.min(index, titlesToPickFrom.length - 1);
|
||||
const title = titlesToPickFrom[safeIndex] || "";
|
||||
const randomTimeAgo = Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000); // Random time within last 7 days
|
||||
|
||||
return {
|
||||
title,
|
||||
href: "https://blog.thirdweb.com/changelog",
|
||||
isRead,
|
||||
createdAt: new Date(Date.now() - randomTimeAgo).toISOString(),
|
||||
id: crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
function randomNotifications(count: number): NotificationMetadata[] {
|
||||
return Array.from({ length: count }, () => generateRandomNotification(false));
|
||||
}
|
||||
|
||||
export const AllUnread: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: randomNotifications(10),
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: randomNotifications(30),
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: [],
|
||||
isPending: true,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: [],
|
||||
isPending: true,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const NoNotifications: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: [],
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: [],
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const AllRead: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: randomNotifications(30).map((x) => ({
|
||||
...x,
|
||||
isRead: true,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: randomNotifications(30).map((x) => ({
|
||||
...x,
|
||||
isRead: true,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const MixedNotifications: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: randomNotifications(10).map((x) => ({
|
||||
...x,
|
||||
isRead: Math.random() > 0.5,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: randomNotifications(30).map((x) => ({
|
||||
...x,
|
||||
isRead: Math.random() > 0.5,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
|
||||
export const NoUnreadInboxNotifications: Story = {
|
||||
args: {
|
||||
notificationTabs: {
|
||||
inbox: {
|
||||
notifications: randomNotifications(5).map((x) => ({
|
||||
...x,
|
||||
isRead: true,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: randomNotifications(30).map((x) => ({
|
||||
...x,
|
||||
isRead: Math.random() > 0.5,
|
||||
})),
|
||||
isPending: false,
|
||||
},
|
||||
},
|
||||
markNotificationAsRead: () => Promise.resolve(),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
import { ScrollShadow } from "@/components/ui/ScrollShadow/ScrollShadow";
|
||||
import { Spinner } from "@/components/ui/Spinner/Spinner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { TabButtons } from "@/components/ui/tabs";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { ActivityIcon, BellIcon, InboxIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { cleanupReadNotifications } from "./fetch-notifications";
|
||||
|
||||
export type NotificationMetadata = {
|
||||
title: string;
|
||||
id: string;
|
||||
href: string;
|
||||
isRead: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type NotificationTabs = {
|
||||
changelogs: {
|
||||
notifications: NotificationMetadata[];
|
||||
isPending: boolean;
|
||||
};
|
||||
inbox: {
|
||||
notifications: NotificationMetadata[];
|
||||
isPending: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function NotificationButtonUI(props: {
|
||||
getChangelogs: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const inboxNotificationsQuery = useQuery({
|
||||
queryKey: ["inbox-notifications"],
|
||||
queryFn: props.getInboxNotifications,
|
||||
});
|
||||
|
||||
const changelogsQuery = useQuery({
|
||||
queryKey: ["changelogs"],
|
||||
queryFn: props.getChangelogs,
|
||||
});
|
||||
|
||||
const [readNotifications, setReadNotifications] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const changelogNotifications = useMemo(() => {
|
||||
return (changelogsQuery.data || []).map((notification) => ({
|
||||
...notification,
|
||||
isRead: notification.isRead || readNotifications.has(notification.id),
|
||||
}));
|
||||
}, [changelogsQuery.data, readNotifications]);
|
||||
|
||||
const inboxNotifications = useMemo(() => {
|
||||
return (inboxNotificationsQuery.data || []).map((notification) => ({
|
||||
...notification,
|
||||
isRead: notification.isRead || readNotifications.has(notification.id),
|
||||
}));
|
||||
}, [inboxNotificationsQuery.data, readNotifications]);
|
||||
|
||||
// Ensure we don't keep adding things to the IndexedDB storage by removing notification ids that are no longer being displayed
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
if (changelogNotifications.length > 0 && inboxNotifications.length > 0) {
|
||||
cleanupReadNotifications({
|
||||
changelogs: changelogNotifications,
|
||||
updates: inboxNotifications,
|
||||
});
|
||||
}
|
||||
}, [changelogNotifications, inboxNotifications]);
|
||||
|
||||
const markAsRead = useCallback(
|
||||
async (id: string) => {
|
||||
setReadNotifications((prev) => new Set([...prev, id]));
|
||||
await props.markNotificationAsRead(id);
|
||||
},
|
||||
[props.markNotificationAsRead],
|
||||
);
|
||||
|
||||
const notificationTabs: NotificationTabs = {
|
||||
inbox: {
|
||||
notifications: inboxNotifications,
|
||||
isPending: inboxNotificationsQuery.isPending,
|
||||
},
|
||||
changelogs: {
|
||||
notifications: changelogNotifications,
|
||||
isPending: changelogsQuery.isPending,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationButtonInner
|
||||
notificationTabs={notificationTabs}
|
||||
markNotificationAsRead={markAsRead}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationButtonInner(props: {
|
||||
notificationTabs: NotificationTabs;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const hasUnreadInboxNotifications =
|
||||
props.notificationTabs.inbox.notifications.some((x) => !x.isRead);
|
||||
const [tab, setTab] = useState<keyof NotificationTabs>("inbox");
|
||||
|
||||
const unreadNotifications = {
|
||||
inbox: props.notificationTabs.inbox.notifications.filter((x) => !x.isRead),
|
||||
changelogs: props.notificationTabs.changelogs.notifications.filter(
|
||||
(x) => !x.isRead,
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
className="relative size-10 rounded-full bg-background p-0"
|
||||
variant="outline"
|
||||
>
|
||||
<BellIcon className="size-4 text-muted-foreground" />
|
||||
{hasUnreadInboxNotifications && (
|
||||
<div className="absolute top-0 right-0 flex size-2.5 items-center justify-center rounded-full bg-red-500" />
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="!z-50 ml-4 w-[calc(100vw-2rem)] overflow-hidden rounded-lg border border-border p-0 shadow-xl lg:ml-0 lg:w-[500px]"
|
||||
align="end"
|
||||
sideOffset={10}
|
||||
side="bottom"
|
||||
>
|
||||
<TabButtons
|
||||
tabClassName="!text-sm"
|
||||
tabContainerClassName="px-2 pt-1.5"
|
||||
tabs={[
|
||||
{
|
||||
isActive: tab === "inbox",
|
||||
name: (
|
||||
<BadgeTab
|
||||
name="Inbox"
|
||||
unreadCount={unreadNotifications.inbox.length}
|
||||
/>
|
||||
),
|
||||
onClick: () => setTab("inbox"),
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
isActive: tab === "changelogs",
|
||||
name: (
|
||||
<BadgeTab
|
||||
name="Changelog"
|
||||
unreadCount={unreadNotifications.changelogs.length}
|
||||
/>
|
||||
),
|
||||
onClick: () => setTab("changelogs"),
|
||||
isEnabled: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{tab === "inbox" && (
|
||||
<NotificationContent
|
||||
notifications={props.notificationTabs.inbox.notifications}
|
||||
isPending={props.notificationTabs.inbox.isPending}
|
||||
icon={InboxIcon}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "changelogs" && (
|
||||
<NotificationContent
|
||||
notifications={props.notificationTabs.changelogs.notifications}
|
||||
isPending={props.notificationTabs.changelogs.isPending}
|
||||
icon={ActivityIcon}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function BadgeTab(props: {
|
||||
name: string;
|
||||
unreadCount: number;
|
||||
}) {
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
{props.name}
|
||||
{props.unreadCount > 0 && (
|
||||
<span className="rounded-lg bg-accent px-1.5 py-1 text-xs">
|
||||
{props.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationContent(props: {
|
||||
notifications: NotificationMetadata[];
|
||||
isPending: boolean;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<ScrollShadow
|
||||
scrollableClassName="h-[70vh] overscroll-contain"
|
||||
shadowClassName="z-20"
|
||||
shadowColor="hsl(var(--background))"
|
||||
>
|
||||
{props.notifications?.map((notification) => (
|
||||
<NotificationLink
|
||||
notification={notification}
|
||||
key={notification.id}
|
||||
icon={props.icon}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
))}
|
||||
|
||||
{props.isPending && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner className="size-10" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!props.isPending && props.notifications.length === 0 && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||
<div>
|
||||
<div className="mb-3 flex justify-center">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-card text-muted-foreground">
|
||||
<props.icon className="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
No Notifications
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationLink(props: {
|
||||
notification: NotificationMetadata;
|
||||
icon: React.FC<{ className?: string }>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const { notification } = props;
|
||||
|
||||
const link = new URL(notification.href);
|
||||
link.searchParams.append("utm_source", "notification");
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (!notification.isRead) {
|
||||
await props.markNotificationAsRead(notification.id);
|
||||
}
|
||||
}, [notification.id, notification.isRead, props.markNotificationAsRead]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
className="relative flex items-start gap-4 border-b px-4 py-4 hover:bg-accent/50"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="relative flex size-10 shrink-0 items-center justify-center rounded-full border bg-card text-muted-foreground">
|
||||
<props.icon className="size-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="grow">
|
||||
<h3 className="font-medium text-foreground text-sm">
|
||||
{notification.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{formatDistance(new Date(notification.createdAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-6 items-center justify-center self-stretch">
|
||||
{!notification.isRead && (
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-500 opacity-75" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { get, set } from "idb-keyval";
|
||||
import type { NotificationMetadata } from "./NotificationButton";
|
||||
|
||||
type ChangelogItem = {
|
||||
published_at: string;
|
||||
title: string;
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
// Note: This saving read notifications in IndexedDB is temporary implementation - This will soon be replaced by a proper API setup
|
||||
|
||||
const READ_NOTIFICATIONS_KEY = "thirdweb:read-notifications";
|
||||
|
||||
// get set of notification ids marked as read
|
||||
async function getReadNotificationIds(): Promise<Set<string>> {
|
||||
const readIds = (await get<string[]>(READ_NOTIFICATIONS_KEY)) || [];
|
||||
return new Set(readIds);
|
||||
}
|
||||
|
||||
// fetches last 20 posts with tag "changelog" notifications
|
||||
async function fetchGhostPosts(tag: string) {
|
||||
const res = await fetch(
|
||||
`https://thirdweb.ghost.io/ghost/api/content/posts/?key=49c62b5137df1c17ab6b9e46e3&fields=id,title,url,published_at&filter=tag:${tag}&visibility:public&limit=20`,
|
||||
);
|
||||
const json = await res.json();
|
||||
return json.posts as ChangelogItem[];
|
||||
}
|
||||
|
||||
// Clean up the indexedDB storage of read notification IDs
|
||||
// This is to prevent the storage from growing indefinitely
|
||||
// Remove notification IDs from storage that are no longer shown to the user
|
||||
export async function cleanupReadNotifications(params: {
|
||||
changelogs: NotificationMetadata[];
|
||||
updates: NotificationMetadata[];
|
||||
}) {
|
||||
const { changelogs, updates } = params;
|
||||
const readIds = await getReadNotificationIds();
|
||||
|
||||
// Get all current notification IDs
|
||||
const currentIds = new Set([
|
||||
...changelogs.map((item) => item.id),
|
||||
...updates.map((item) => item.id),
|
||||
]);
|
||||
|
||||
// remove ids that are no longer being displayed
|
||||
const usedIds = new Set(
|
||||
Array.from(readIds).filter((id) => currentIds.has(id)),
|
||||
);
|
||||
|
||||
await set(READ_NOTIFICATIONS_KEY, Array.from(usedIds));
|
||||
}
|
||||
|
||||
export async function markNotificationAsRead(id: string) {
|
||||
const readIds = await getReadNotificationIds();
|
||||
readIds.add(id);
|
||||
|
||||
// Clean up old notification IDs periodically (every 10th notification marked as read)
|
||||
await set(READ_NOTIFICATIONS_KEY, Array.from(readIds));
|
||||
}
|
||||
|
||||
export async function getChangelogNotifications(): Promise<
|
||||
NotificationMetadata[]
|
||||
> {
|
||||
const [changelogs, readIds] = await Promise.all([
|
||||
fetchGhostPosts("changelog"),
|
||||
getReadNotificationIds(),
|
||||
]);
|
||||
|
||||
return changelogs.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
href: item.url,
|
||||
isRead: readIds.has(item.id),
|
||||
createdAt: item.published_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getInboxNotifications(): Promise<NotificationMetadata[]> {
|
||||
const [changelogs, readIds] = await Promise.all([
|
||||
fetchGhostPosts("update"),
|
||||
getReadNotificationIds(),
|
||||
]);
|
||||
|
||||
return changelogs.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
href: item.url,
|
||||
isRead: readIds.has(item.id),
|
||||
createdAt: item.published_at,
|
||||
}));
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { ProjectSelectorUI } from "./ProjectSelectorUI";
|
||||
|
||||
type ProjectSelectorMobileMenuButtonProps = {
|
||||
@@ -14,6 +15,7 @@ type ProjectSelectorMobileMenuButtonProps = {
|
||||
projects: Project[];
|
||||
team: Team;
|
||||
createProject: (team: Team) => void;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
export function ProjectSelectorMobileMenuButton(
|
||||
@@ -46,6 +48,7 @@ export function ProjectSelectorMobileMenuButton(
|
||||
<DynamicHeight>
|
||||
<ProjectSelectorUI
|
||||
currentProject={props.currentProject}
|
||||
client={props.client}
|
||||
projects={props.projects}
|
||||
team={props.team}
|
||||
createProject={() => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CirclePlusIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { SearchInput } from "./SearchInput";
|
||||
|
||||
export function ProjectSelectorUI(props: {
|
||||
@@ -17,6 +18,7 @@ export function ProjectSelectorUI(props: {
|
||||
currentProject: Project | undefined;
|
||||
team: Team;
|
||||
createProject: () => void;
|
||||
client: ThirdwebClient;
|
||||
}) {
|
||||
const { projects, currentProject, team } = props;
|
||||
const [searchProjectTerm, setSearchProjectTerm] = useState("");
|
||||
@@ -60,8 +62,11 @@ export function ProjectSelectorUI(props: {
|
||||
>
|
||||
<Link href={`/team/${team.slug}/${project.slug}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* TODO - set Image */}
|
||||
<ProjectAvatar src="" className="size-6" />
|
||||
<ProjectAvatar
|
||||
src={project.image || ""}
|
||||
className="size-6"
|
||||
client={props.client}
|
||||
/>
|
||||
<span className="truncate"> {project.name}</span>
|
||||
</div>
|
||||
{isSelected && (
|
||||
|
||||
+4
-3
@@ -9,10 +9,10 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useThirdwebClient } from "@/constants/thirdweb.client";
|
||||
import type { Account } from "@3rdweb-sdk/react/hooks/useApi";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { ThirdwebClient } from "thirdweb";
|
||||
import { ProjectSelectorUI } from "./ProjectSelectorUI";
|
||||
import { TeamSelectionUI } from "./TeamSelectionUI";
|
||||
|
||||
@@ -23,6 +23,7 @@ type TeamSwitcherProps = {
|
||||
focus: "project-selection" | "team-selection";
|
||||
createProject: (team: Team) => void;
|
||||
account: Pick<Account, "email" | "id"> | undefined;
|
||||
client: ThirdwebClient;
|
||||
};
|
||||
|
||||
export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
|
||||
@@ -31,7 +32,6 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
|
||||
const [hoveredTeam, setHoveredTeam] = useState<Team>();
|
||||
const projectsToShowOfTeam =
|
||||
hoveredTeam || currentTeam || teamsAndProjects[0]?.team;
|
||||
const client = useThirdwebClient();
|
||||
|
||||
// if we can't find a single team associated with this user - something is really wrong
|
||||
if (!projectsToShowOfTeam) {
|
||||
@@ -93,7 +93,7 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
|
||||
: undefined
|
||||
}
|
||||
account={props.account}
|
||||
client={client}
|
||||
client={props.client}
|
||||
/>
|
||||
|
||||
{/* Right */}
|
||||
@@ -102,6 +102,7 @@ export function TeamAndProjectSelectorPopoverButton(props: TeamSwitcherProps) {
|
||||
currentProject={props.currentProject}
|
||||
projects={projectsToShow}
|
||||
team={projectsToShowOfTeam}
|
||||
client={props.client}
|
||||
createProject={() => {
|
||||
setOpen(false);
|
||||
props.createProject(projectsToShowOfTeam);
|
||||
|
||||
@@ -52,6 +52,11 @@ function Variants(props: {
|
||||
if (!team1 || !team2 || !team3 || !team3Project) {
|
||||
return <div> failed to get team and project stubs </div>;
|
||||
}
|
||||
|
||||
const getChangelogsStub = () => Promise.resolve([]);
|
||||
const getInboxNotificationsStub = () => Promise.resolve([]);
|
||||
const markNotificationAsReadStub = () => Promise.resolve();
|
||||
|
||||
return (
|
||||
<ThirdwebProvider>
|
||||
<div className="flex min-h-dvh flex-col gap-6 bg-background py-10">
|
||||
@@ -70,6 +75,9 @@ function Variants(props: {
|
||||
id: "1",
|
||||
}}
|
||||
client={client}
|
||||
getChangelogNotifications={getChangelogsStub}
|
||||
getInboxNotifications={getInboxNotificationsStub}
|
||||
markNotificationAsRead={markNotificationAsReadStub}
|
||||
/>
|
||||
</div>
|
||||
</BadgeContainer>
|
||||
@@ -89,6 +97,9 @@ function Variants(props: {
|
||||
connectButton={<ConnectButtonStub />}
|
||||
createProject={() => {}}
|
||||
client={client}
|
||||
getChangelogNotifications={getChangelogsStub}
|
||||
getInboxNotifications={getInboxNotificationsStub}
|
||||
markNotificationAsRead={markNotificationAsReadStub}
|
||||
/>
|
||||
</div>
|
||||
</BadgeContainer>
|
||||
@@ -108,6 +119,9 @@ function Variants(props: {
|
||||
connectButton={<ConnectButtonStub />}
|
||||
createProject={() => {}}
|
||||
client={client}
|
||||
getChangelogNotifications={getChangelogsStub}
|
||||
getInboxNotifications={getInboxNotificationsStub}
|
||||
markNotificationAsRead={markNotificationAsReadStub}
|
||||
/>
|
||||
</div>
|
||||
</BadgeContainer>
|
||||
|
||||
@@ -10,6 +10,10 @@ import { SecondaryNav } from "../../../components/Header/SecondaryNav/SecondaryN
|
||||
import { MobileBurgerMenuButton } from "../../../components/MobileBurgerMenuButton";
|
||||
import { TeamPlanBadge } from "../../../components/TeamPlanBadge";
|
||||
import { ThirdwebMiniLogo } from "../../../components/ThirdwebMiniLogo";
|
||||
import {
|
||||
NotificationButtonUI,
|
||||
type NotificationMetadata,
|
||||
} from "../NotificationButton/NotificationButton";
|
||||
import { ProjectSelectorMobileMenuButton } from "./ProjectSelectorMobileMenuButton";
|
||||
import { TeamAndProjectSelectorPopoverButton } from "./TeamAndProjectSelectorPopoverButton";
|
||||
import { TeamSelectorMobileMenuButton } from "./TeamSelectorMobileMenuButton";
|
||||
@@ -26,6 +30,9 @@ export type TeamHeaderCompProps = {
|
||||
createProject: (team: Team) => void;
|
||||
client: ThirdwebClient;
|
||||
accountAddress: string;
|
||||
getChangelogNotifications: () => Promise<NotificationMetadata[]>;
|
||||
getInboxNotifications: () => Promise<NotificationMetadata[]>;
|
||||
markNotificationAsRead: (id: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
@@ -68,6 +75,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
focus="team-selection"
|
||||
createProject={props.createProject}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -79,8 +87,11 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
href={`/team/${props.currentTeam.slug}/${props.currentProject.slug}`}
|
||||
className="flex flex-row items-center gap-2 font-semibold text-sm"
|
||||
>
|
||||
{/* TODO - set project avatar image */}
|
||||
<ProjectAvatar src="" className="size-6" />
|
||||
<ProjectAvatar
|
||||
src={props.currentProject.image || ""}
|
||||
className="size-6"
|
||||
client={props.client}
|
||||
/>
|
||||
{props.currentProject.name}
|
||||
</Link>
|
||||
|
||||
@@ -91,6 +102,7 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
focus="project-selection"
|
||||
createProject={props.createProject}
|
||||
account={props.account}
|
||||
client={props.client}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -103,6 +115,9 @@ export function TeamHeaderDesktopUI(props: TeamHeaderCompProps) {
|
||||
connectButton={props.connectButton}
|
||||
client={props.client}
|
||||
accountAddress={props.accountAddress}
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
@@ -171,19 +186,28 @@ export function TeamHeaderMobileUI(props: TeamHeaderCompProps) {
|
||||
projects={projects}
|
||||
team={props.currentTeam}
|
||||
createProject={props.createProject}
|
||||
client={props.client}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MobileBurgerMenuButton
|
||||
type="loggedIn"
|
||||
email={props.account?.email}
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<NotificationButtonUI
|
||||
getChangelogs={props.getChangelogNotifications}
|
||||
getInboxNotifications={props.getInboxNotifications}
|
||||
markNotificationAsRead={props.markNotificationAsRead}
|
||||
/>
|
||||
|
||||
<MobileBurgerMenuButton
|
||||
type="loggedIn"
|
||||
email={props.account.email}
|
||||
logout={props.logout}
|
||||
connectButton={props.connectButton}
|
||||
accountAddress={props.accountAddress}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ import { useCallback, useState } from "react";
|
||||
import { useActiveWallet, useDisconnect } from "thirdweb/react";
|
||||
import { LazyCreateProjectDialog } from "../../../../components/settings/ApiKeys/Create/LazyCreateAPIKeyDialog";
|
||||
import { doLogout } from "../../../login/auth-actions";
|
||||
import {
|
||||
getChangelogNotifications,
|
||||
getInboxNotifications,
|
||||
markNotificationAsRead,
|
||||
} from "../NotificationButton/fetch-notifications";
|
||||
import {
|
||||
type TeamHeaderCompProps,
|
||||
TeamHeaderDesktopUI,
|
||||
@@ -58,6 +63,9 @@ export function TeamHeaderLoggedIn(props: {
|
||||
},
|
||||
client: getThirdwebClient(),
|
||||
accountAddress: props.accountAddress,
|
||||
getChangelogNotifications: getChangelogNotifications,
|
||||
getInboxNotifications: getInboxNotifications,
|
||||
markNotificationAsRead: markNotificationAsRead,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { getTeams } from "@/api/team";
|
||||
import { getDefaultTeam } from "@/api/team";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
export default async function TeamRootPage() {
|
||||
// get all teams, then go to the first team
|
||||
const teams = await getTeams();
|
||||
|
||||
const firstTeam = teams?.[0];
|
||||
const firstTeam = await getDefaultTeam();
|
||||
if (!firstTeam) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -214,11 +214,10 @@ export const MismatchButton = forwardRef<
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"z-[10001] gap-0 p-0",
|
||||
"gap-0 p-0",
|
||||
dialog === "no-funds" && "max-w-[480px]",
|
||||
dialog === "pay" && "max-w-[360px] border-none bg-transparent",
|
||||
)}
|
||||
dialogOverlayClassName="z-[10000]"
|
||||
dialogCloseClassName="focus:ring-0"
|
||||
>
|
||||
<DynamicHeight>
|
||||
|
||||
@@ -190,10 +190,7 @@ export const CmdKSearchModal = (props: {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="z-[10000001] gap-0 p-0"
|
||||
dialogOverlayClassName="z-[10000000]"
|
||||
>
|
||||
<DialogContent className="gap-0 p-0">
|
||||
{/* Title */}
|
||||
<DynamicHeight>
|
||||
<div className="p-6">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user