pay dashboard (#3393)

<!-- start pr-codex -->

## PR-Codex overview
This PR updates package versions, improves UI components, enhances styling, and refactors URLs for consistency.

### Detailed summary
- Updated `pnpm` to version `9.4.0`
- Enhanced UI components like `Button`, `ScrollShadow`, `Badge`, and `Skeleton`
- Refactored URLs for consistency
- Improved styling and transitions in various components

> The following files were skipped due to too many changes: `apps/dashboard/src/@/components/ui/DynamicHeight.tsx`, `apps/dashboard/src/theme/index.ts`, `apps/dashboard/src/contract-ui/tabs/overview/components/Analytics.tsx`, `apps/dashboard/src/@/components/ui/CopyAddressButton.tsx`, `apps/dashboard/src/components/analytics/custom-tooltip.tsx`, `apps/dashboard/src/contract-ui/tabs/analytics/page.tsx`, `apps/dashboard/src/pages/_app.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayNewCustomers.ts`, `apps/dashboard/src/components/analytics/stack-tooltip.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/ExportToCSVButton.tsx`, `apps/dashboard/src/@/components/pagination-buttons.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayCustomers.ts`, `apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayVolume.ts`, `apps/dashboard/src/app/(dashboard)/(chain)/chainlist/components/client/pagination.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/hooks/usePayPurchases.ts`, `apps/dashboard/src/@/components/ui/calendar.tsx`, `apps/dashboard/src/@/styles/globals.css`, `apps/dashboard/src/components/pay/PayAnalytics/components/common.tsx`, `apps/dashboard/src/@/components/ui/DatePickerWithRange.tsx`, `apps/dashboard/src/@/components/ui/use-toast.ts`, `apps/dashboard/src/components/analytics/area-chart.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/TotalVolumePieChart.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/PaymentsSuccessRate.tsx`, `apps/dashboard/src/@/components/ui/tabs.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/Payouts.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/PayNewCustomers.tsx`, `apps/dashboard/src/@/components/ui/toast.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/PayAnalytics.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/TotalPayVolume.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/PayCustomersTable.tsx`, `apps/dashboard/src/pages/dashboard/connect/pay.tsx`, `apps/dashboard/src/components/analytics/bar-chart.tsx`, `apps/dashboard/src/@/components/ui/select.tsx`, `apps/dashboard/src/components/analytics/auto-bar-chart.tsx`, `apps/dashboard/src/components/pay/PayAnalytics/components/PaymentHistory.tsx`, `pnpm-lock.yaml`

> ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}`

<!-- end pr-codex -->
This commit is contained in:
jnsdls
2024-06-23 16:11:16 +00:00
parent 4490a68c68
commit e372a3f413
56 changed files with 9347 additions and 4918 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ runs:
# pnpm for our dependencies
- uses: pnpm/action-setup@v3
with:
version: 8
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
+7 -1
View File
@@ -9,5 +9,11 @@
"typescript.preferences.autoImportFileExcludePatterns": [
"./packages/thirdweb/src/exports"
],
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib",
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+14 -2
View File
@@ -31,12 +31,15 @@
"@emotion/styled": "11.11.5",
"@hookform/resolvers": "^3.6.0",
"@n8tb1t/use-scroll-position": "^2.0.3",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-checkbox": "^1.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-popover": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.0",
"@react-icons/all-files": "^4.1.0",
"@sentry/nextjs": "^8.8.0",
@@ -84,6 +87,7 @@
"qrcode": "^1.5.3",
"react": "18.3.1",
"react-children-utilities": "^2.10.0",
"react-day-picker": "^8.10.1",
"react-dom": "18.3.1",
"react-dropzone": "^14.2.3",
"react-hook-form": "7.52.0",
@@ -160,8 +164,16 @@
"tw-components",
"contract-ui"
],
"exclude": ["node_modules", "types", "tw-components"],
"exclude": [
"node_modules",
"types",
"tw-components"
],
"entrypoints": []
},
"browserslist": ["defaults", "unreleased versions", "not UCAndroid > 0"]
"browserslist": [
"defaults",
"unreleased versions",
"not UCAndroid > 0"
]
}
@@ -0,0 +1,99 @@
"use client";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
export const PaginationButtons = (props: {
activePage: number;
totalPages: number;
onPageClick: (page: number) => void;
}) => {
const { activePage, totalPages, onPageClick: setPage } = props;
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={activePage === 1}
onClick={() => {
setPage(activePage - 1);
}}
/>
</PaginationItem>
{activePage - 3 > 0 && (
<PaginationItem className="max-sm:hidden">
<PaginationEllipsis />
</PaginationItem>
)}
{activePage - 2 > 0 && (
<PaginationItem className="max-sm:hidden">
<PaginationLink
onClick={() => {
setPage(activePage - 2);
}}
>
{activePage - 2}
</PaginationLink>
</PaginationItem>
)}
{activePage - 1 > 0 && (
<PaginationItem>
<PaginationLink
onClick={() => {
setPage(activePage - 1);
}}
>
{activePage - 1}
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationLink isActive>{activePage}</PaginationLink>
</PaginationItem>
{activePage + 1 <= totalPages && (
<PaginationItem>
<PaginationLink
onClick={() => {
setPage(activePage + 1);
}}
>
{activePage + 1}
</PaginationLink>
</PaginationItem>
)}
{activePage + 2 <= totalPages && (
<PaginationItem className="max-sm:hidden">
<PaginationLink
onClick={() => {
setPage(activePage + 2);
}}
>
{activePage + 2}
</PaginationLink>
</PaginationItem>
)}
{activePage + 3 <= totalPages && (
<PaginationItem className="max-sm:hidden">
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
disabled={activePage === totalPages}
onClick={() => {
setPage(activePage + 1);
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
};
@@ -0,0 +1,52 @@
"use client";
import { CheckIcon, CopyIcon } from "lucide-react";
import { useState } from "react";
import { cn } from "../../lib/utils";
import { Button } from "./button";
import { ToolTipLabel } from "./tooltip";
export function CopyAddressButton(props: {
address: string;
className?: string;
iconClassName?: string;
variant?:
| "primary"
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost";
}) {
const [isCopied, setIsCopied] = useState(false);
const shortenedAddress = `${props.address.slice(0, 6)}...${props.address.slice(0, 4)}`;
return (
<ToolTipLabel label="Copy address">
<Button
variant={props.variant || "outline"}
aria-label="Copy"
className={cn(
"h-auto w-auto px-2.5 py-1.5 flex gap-2 text-xs rounded-lg font-mono text-foreground",
props.className,
)}
onClick={() => {
navigator.clipboard.writeText(props.address);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1000);
}}
>
{isCopied ? (
<CheckIcon
className={cn("size-3 text-green-500", props.iconClassName)}
/>
) : (
<CopyIcon
className={cn("size-3 text-muted-foreground", props.iconClassName)}
/>
)}
{shortenedAddress}
</Button>
</ToolTipLabel>
);
}
@@ -0,0 +1,150 @@
"use client";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { format, isBefore } from "date-fns";
import {
Calendar as CalendarIcon,
CalendarX2Icon,
ChevronDownIcon,
} from "lucide-react";
import React from "react";
import { DynamicHeight } from "./DynamicHeight";
import { TabButtons } from "./tabs";
export function DatePickerWithRange(props: {
from: Date;
to: Date;
setFrom: (from: Date) => void;
setTo: (to: Date) => void;
className?: string;
header?: React.ReactNode;
footer?: React.ReactNode;
labelOverride?: string;
}) {
const [screen, setScreen] = React.useState<"from" | "to">("from");
const { from, to, setFrom, setTo } = props;
const isValid = React.useMemo(() => isBefore(from, to), [from, to]);
const [isOpen, setIsOpen] = React.useState(false);
return (
<Popover
open={isOpen}
onOpenChange={(v) => {
if (!v) {
setScreen("from");
}
setIsOpen(v);
}}
>
{/* Button */}
<PopoverTrigger asChild>
<Button
id="date"
variant={"outline"}
className={cn(
"justify-start text-left font-normal gap-2",
props.className,
)}
>
<CalendarIcon className="h-4 w-4" />
{props.labelOverride || (
<>
{format(from, "LLL dd, y")} - {format(to, "LLL dd, y")}
</>
)}
<ChevronDownIcon className="h-4 w-4 ml-auto text-muted-foreground" />
</Button>
</PopoverTrigger>
{/* Popover */}
<PopoverContent className="w-auto p-0" align="start">
<DynamicHeight>
<div>
{!isValid && (
<p className="text-sm pt-2 justify-center text-destructive-foreground text-center flex items-center gap-2">
<CalendarX2Icon className="h-4 w-4" />
Invalid date range
</p>
)}
{props.header}
<div className="px-4">
<TabButtons
tabClassName="!text-sm"
activeTabClassName="!bg-inverted !text-inverted-foreground"
tabContainerClassName="gap-2"
tabs={[
{
name: "From",
onClick: () => setScreen("from"),
isActive: screen === "from",
isEnabled: true,
},
{
name: "To",
onClick: () => setScreen("to"),
isActive: screen === "to",
isEnabled: true,
},
]}
/>
</div>
{screen === "from" && (
<Calendar
key={from.toString()}
mode="range"
selected={{
from,
to,
}}
defaultMonth={from}
onDayClick={(newFrom) => {
if (isBefore(newFrom, to)) {
setFrom(newFrom);
}
}}
classNames={{
day_range_start: "!bg-inverted",
day_range_end:
"!bg-inverted/20 !text-inverted pointer-events-none",
}}
/>
)}
{screen === "to" && (
<Calendar
key={to.toString()}
mode="range"
selected={{
from,
to,
}}
defaultMonth={to}
onDayClick={(newTo) => {
if (isBefore(from, newTo)) {
setTo(newTo);
}
}}
classNames={{
day_range_end: "!bg-inverted",
day_range_start:
"!bg-inverted/20 !text-inverted pointer-events-none",
}}
/>
)}
{props.footer}
</div>
</DynamicHeight>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,58 @@
"use client";
import type React from "react";
import { useLayoutEffect, useRef, useState } from "react";
export function DynamicHeight(props: {
children: React.ReactNode;
maxHeight?: string;
transition?: string;
}) {
const { height, elementRef } = useHeightObserver();
return (
<div
style={{
height: height ? `${height}px` : "auto",
transition:
props.transition ||
"height 210ms cubic-bezier(0.175, 0.885, 0.32, 1.1)",
overflow: "hidden",
boxSizing: "border-box",
}}
>
<div
ref={elementRef}
style={{
maxHeight: props.maxHeight,
}}
>
{props.children}
</div>
</div>
);
}
export function useHeightObserver() {
const elementRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | undefined>();
useLayoutEffect(() => {
const element = elementRef.current;
if (!element) {
return;
}
const resizeObserver = new ResizeObserver(() => {
setHeight(element.scrollHeight);
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}, []);
return { height, elementRef };
}
@@ -8,6 +8,7 @@ export function ScrollShadow(props: {
children: React.ReactNode;
className?: string;
scrollableClassName?: string;
disableTopShadow?: boolean;
}) {
const scrollableEl = useRef<HTMLDivElement>(null);
const shadowTopEl = useRef<HTMLDivElement>(null);
@@ -78,6 +79,7 @@ export function ScrollShadow(props: {
ref={shadowTopEl}
style={{
opacity: "0",
display: props.disableTopShadow ? "none" : "block",
}}
/>
<div
@@ -0,0 +1,7 @@
"use client";
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };
+4 -1
View File
@@ -4,7 +4,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 leading-4",
{
variants: {
variant: {
@@ -14,7 +14,10 @@ const badgeVariants = cva(
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
warning: "border-transparent bg-warning text-warning-foreground",
outline: "text-foreground",
success:
"border-transparent bg-success text-success-foreground hover:bg-success/80",
},
},
defaultVariants: {
@@ -55,4 +55,4 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = "Button";
export { Button };
export { Button, buttonVariants };
@@ -0,0 +1,66 @@
"use client";
import { ChevronLeft, ChevronRight } from "lucide-react";
import type * as React from "react";
import { DayPicker } from "react-day-picker";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(
buttonVariants({ variant: "ghost" }),
"h-9 w-9 p-0 font-normal aria-selected:opacity-100",
),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: () => <ChevronLeft className="h-4 w-4" />,
IconRight: () => <ChevronRight className="h-4 w-4" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };
@@ -0,0 +1,160 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex gap-2 h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};
@@ -6,10 +6,41 @@ function Skeleton({
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
className={cn("animate-skeleton rounded-md bg-muted", className)}
{...props}
/>
);
}
export { Skeleton };
function SkeletonContainer<T>(props: {
loadedData?: T;
skeletonData: T;
className?: string;
render: (data: T, isSkeleton: boolean) => React.ReactNode;
style?: React.CSSProperties;
}) {
const isLoading = props.loadedData === undefined;
return (
<div
style={props.style}
aria-hidden={isLoading ? "true" : "false"}
className={cn(
isLoading && "animate-pulse rounded-lg bg-muted",
props.className,
)}
>
<div className={cn(isLoading && "invisible")}>
<div
className={cn(
"transitino-opacity duration-300",
isLoading ? "opacity-0" : "opacity-100",
)}
>
{props.render(props.loadedData ?? props.skeletonData, !isLoading)}
</div>
</div>
</div>
);
}
export { Skeleton, SkeletonContainer };
+96 -41
View File
@@ -5,42 +5,10 @@ import { ScrollShadow } from "./ScrollShadow/ScrollShadow";
import { Button } from "./button";
export function TabLinks(props: {
links: {
name: string;
href: string;
isActive: boolean;
isEnabled?: boolean;
icon?: React.FC<{ className?: string }>;
}[];
links: { name: string; href: string; isActive: boolean }[];
}) {
const containerRef = useRef<HTMLDivElement>(null);
const lineRef = useRef<HTMLDivElement>(null);
const [activeTabEl, setActiveTabEl] = useState<HTMLAnchorElement | null>(
null,
);
const activeTabRef = useCallback((el: HTMLAnchorElement | null) => {
setActiveTabEl(el);
}, []);
useLayoutEffect(() => {
if (activeTabEl && containerRef.current && lineRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
const lineEl = lineRef.current;
const rect = activeTabEl.getBoundingClientRect();
lineEl.style.width = `${rect.width}px`;
lineEl.style.transform = `translateX(${rect.left - containerRect.left}px)`;
setTimeout(() => {
lineEl.style.transition = "transform 0.3s, width 0.3s";
}, 0);
activeTabEl.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}
}, [activeTabEl]);
const { containerRef, lineRef, activeTabRef } =
useUnderline<HTMLAnchorElement>();
return (
<div className="relative">
@@ -52,15 +20,12 @@ export function TabLinks(props: {
<Link
data-active={tab.isActive}
ref={tab.isActive ? activeTabRef : undefined}
href={tab.isEnabled ? tab.href : "#"}
href={tab.href}
className={cn(
"rounded-lg hover:bg-accent px-2 lg:px-3 font-medium text-sm lg:text-base relative h-auto inline-flex gap-1.5 items-center",
!tab.isActive &&
"text-muted-foreground hover:text-foreground",
!tab.isEnabled && "cursor-not-allowed opacity-50",
"rounded-lg hover:bg-muted px-3 font-medium text-sm lg:text-base relative h-auto",
!tab.isActive && "opacity-50 hover:opacity-100",
)}
>
{tab.icon && <tab.icon className="size-6" />}
{tab.name}
</Link>
</Button>
@@ -79,3 +44,93 @@ export function TabLinks(props: {
</div>
);
}
export function TabButtons(props: {
tabs: {
name: string;
onClick: () => void;
isActive: boolean;
isEnabled?: boolean;
icon?: React.FC<{ className?: string }>;
}[];
tabClassName?: string;
activeTabClassName?: string;
tabContainerClassName?: string;
}) {
const { containerRef, lineRef, activeTabRef } =
useUnderline<HTMLButtonElement>();
return (
<div className="relative">
<ScrollShadow scrollableClassName="pb-[8px] relative">
<div
className={cn("flex", props.tabContainerClassName)}
ref={containerRef}
>
{props.tabs.map((tab) => {
return (
<Button
key={tab.name}
variant="ghost"
ref={tab.isActive ? activeTabRef : undefined}
className={cn(
"rounded-lg hover:bg-accent px-2 lg:px-3 font-medium text-sm lg:text-base relative h-auto inline-flex gap-1.5 items-center",
!tab.isActive &&
"text-muted-foreground hover:text-foreground",
!tab.isEnabled && "cursor-not-allowed opacity-50",
props.tabClassName,
tab.isActive && props.activeTabClassName,
)}
onClick={tab.isEnabled ? tab.onClick : undefined}
>
{tab.icon && <tab.icon className="size-6" />}
{tab.name}
</Button>
);
})}
</div>
{/* Active line */}
<div
ref={lineRef}
className="absolute left-0 bottom-0 z-10 h-[2px] bg-foreground rounded-lg fade-in-0 animate-in"
/>
</ScrollShadow>
{/* Bottom line */}
<div className="h-[1px] bg-border -translate-y-[2px]" />
</div>
);
}
function useUnderline<El extends HTMLElement>() {
const containerRef = useRef<HTMLDivElement>(null);
const lineRef = useRef<HTMLDivElement>(null);
const [activeTabEl, setActiveTabEl] = useState<El | null>(null);
const activeTabRef = useCallback((el: El | null) => {
setActiveTabEl(el);
}, []);
useLayoutEffect(() => {
if (activeTabEl && containerRef.current && lineRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
const lineEl = lineRef.current;
const rect = activeTabEl.getBoundingClientRect();
lineEl.style.width = `${rect.width}px`;
lineEl.style.transform = `translateX(${
rect.left - containerRect.left
}px)`;
setTimeout(() => {
lineEl.style.transition = "transform 0.3s, width 0.3s";
}, 0);
activeTabEl.scrollIntoView({
behavior: "smooth",
block: "nearest",
inline: "center",
});
}
}, [activeTabEl]);
return { containerRef, lineRef, activeTabRef };
}
@@ -0,0 +1,129 @@
"use client";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { type VariantProps, cva } from "class-variance-authority";
import { X } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[10000000] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px] gap-4",
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};
@@ -0,0 +1,31 @@
"use client";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
import { useToast } from "@/components/ui/use-toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => (
<Toast key={id} {...props}>
<div className="grid gap-1 z-50">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
{action}
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
);
}
@@ -0,0 +1,192 @@
/* eslint-disable no-restricted-syntax */
"use client";
import { useEffect, useState } from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 5;
const TOAST_REMOVE_DELAY = 5000; // 5 seconds
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
for (const toast of state.toasts) {
addToRemoveQueue(toast.id);
}
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
for (const listener of listeners) {
listener(memoryState);
}
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open: boolean) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = useState<State>(memoryState);
// listeners is global, that's fine, I guess?
// we add a listener on mount and remove it on unmount
useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, []);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };
+64 -31
View File
@@ -4,54 +4,77 @@
@layer base {
:root {
--background: 0 0% 91.5%;
--foreground: 0 0% 3.9%;
--card: 0 0% 97%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
/* bg - nuetral */
--background: 0 0% 100%;
--secondary: 0 0% 97%;
--muted: 0 0% 95%;
--accent: 0 0% 92%;
--card: var(--secondary);
--popover: var(--background);
--inverted: var(--foreground);
/* bg - colorful */
--primary: 221.21deg 83.19% 53.33%;
--primary-foreground: 221.21deg 83.19% 53.33%;
--secondary: 0 0% 94%;
--destructive: 0 84.2% 90%;
--success: 142.09 60.56% 80%;
--warning: 40.61deg 96.12% 88%;
/* Text */
--foreground: 0 0% 3.9%;
--card-foreground: 0 0% 3.9%;
--popover-foreground: 240 10% 3.9%;
--primary-foreground: 0 0% 100%;
--secondary-foreground: 0 0% 30%;
--muted: 0 0% 96%;
--muted-foreground: 0 0% 40%;
--accent: 0 0% 100%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--success-foreground: 142.09 70.56% 35.29%;
--destructive-foreground: 0 84.24% 60.2%;
--border: 0 0% 80%;
--input: 0 0% 80%;
--ring: 215.88 100% 60%;
--radius: 0.5rem;
--inverted: var(--foreground);
--inverted-foreground: var(--background);
--warning-foreground: 40.61deg 96.12% 40.4%;
--link-foreground: 221.21deg 83.19% 53.33%;
/* Borders */
--border: 0 0% 80%;
--input: var(--border);
--ring: 215.88 100% 60%;
/* Others */
--radius: 0.5rem;
}
.dark {
/* bg - nuetral */
--background: 0 0% 4.5%;
--foreground: 0 0% 98%;
--card: 0 0% 6%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 215.88 100% 60%;
--primary-foreground: 215.88 100% 65%;
--secondary: 0 0% 6%;
--secondary-foreground: 0 0% 75%;
--muted: 0 0% 8%;
--muted-foreground: 0 0% 63.9%;
--secondary: 0 0% 5.5%;
--muted: 0 0% 9%;
--accent: 0 0% 12%;
--card: var(--secondary);
--popover: var(--background);
--inverted: var(--foreground);
/* bg - colorful */
--primary: 215.88 100% 60%;
--destructive: 0 62.8% 10%;
--success: 156.2 71.6% 10%;
--warning: 40.61deg 96.12% 8%;
/* Text */
--foreground: 0 0% 98%;
--card-foreground: 0 0% 98%;
--popover-foreground: 0 0% 98%;
--primary-foreground: 0 0% 4.5%;
--secondary-foreground: 0 0% 75%;
--muted-foreground: 0 0% 63.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 84.24% 60.2%;
--success-foreground: 156.2 71.6% 66.86%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 215.88 100% 60%;
--warning-foreground: 45.4deg 93.39% 47.45%;
--link-foreground: 215.88 100% 65%;
/* Borders */
--border: 0 0% 14.9%;
--ring: 215.88 100% 60%;
--input: var(--border);
}
}
@@ -63,3 +86,13 @@
@apply bg-background text-foreground;
}
}
@keyframes skeleton {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@@ -67,7 +67,7 @@ export default async function Page() {
</h3>
<Link
href="/explore"
className="text-primary-foreground inline-flex items-center gap-1 text-base hover:text-foreground font-medium"
className="text-link-foreground inline-flex items-center gap-1 text-base hover:text-foreground font-medium"
>
View All
<ChevronRight className="size-5" />
@@ -1,16 +1,8 @@
"use client";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback } from "react";
import { PaginationButtons } from "../../../../../../@/components/pagination-buttons";
type ChainlistPaginationProps = {
totalPages: number;
@@ -33,83 +25,12 @@ export const ChainlistPagination: React.FC<ChainlistPaginationProps> = ({
},
[pathname, searchParams],
);
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
disabled={activePage === 1}
onClick={() => {
router.push(createPageURL(activePage - 1));
}}
/>
</PaginationItem>
{activePage - 3 > 0 && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
{activePage - 2 > 0 && (
<PaginationItem>
<PaginationLink
onClick={() => {
router.push(createPageURL(activePage - 2));
}}
>
{activePage - 2}
</PaginationLink>
</PaginationItem>
)}
{activePage - 1 > 0 && (
<PaginationItem>
<PaginationLink
onClick={() => {
router.push(createPageURL(activePage - 1));
}}
>
{activePage - 1}
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationLink isActive>{activePage}</PaginationLink>
</PaginationItem>
{activePage + 1 <= totalPages && (
<PaginationItem>
<PaginationLink
onClick={() => {
router.push(createPageURL(activePage + 1));
}}
>
{activePage + 1}
</PaginationLink>
</PaginationItem>
)}
{activePage + 2 <= totalPages && (
<PaginationItem>
<PaginationLink
onClick={() => {
router.push(createPageURL(activePage + 2));
}}
>
{activePage + 2}
</PaginationLink>
</PaginationItem>
)}
{activePage + 3 <= totalPages && (
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
disabled={activePage === totalPages}
onClick={() => {
router.push(createPageURL(activePage + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<PaginationButtons
activePage={activePage}
totalPages={totalPages}
onPageClick={(page) => router.push(createPageURL(page))}
/>
);
};
@@ -51,7 +51,7 @@ export async function ChainListRow({
{!isDeprecated && chainMetadata?.gasSponsored && (
<ToolTipLabel label="Gas Sponsored">
<TicketCheckIcon className="text-primary-foreground size-5 z-10 " />
<TicketCheckIcon className="text-link-foreground size-5 z-10 " />
</ToolTipLabel>
)}
@@ -1,4 +1,3 @@
import { Box, type BoxProps, Center } from "@chakra-ui/react";
import { useEffect, useId, useState } from "react";
import {
Area,
@@ -8,7 +7,7 @@ import {
XAxis,
YAxis,
} from "recharts";
import { Text } from "tw-components";
import { cn } from "../../@/lib/utils";
import { CustomToolTip } from "./custom-tooltip";
export type GenericDataType = Record<string, string | number>;
@@ -18,7 +17,7 @@ type IndexType = "date";
export interface AreaChartProps<
TData extends GenericDataType,
TIndexKey extends keyof TData,
> extends BoxProps {
> {
data: TData[];
index: {
id: TIndexKey;
@@ -36,6 +35,8 @@ export interface AreaChartProps<
showXAxis?: boolean;
showYAxis?: boolean;
startEndOnly?: boolean;
className?: string;
isAnimationActive?: boolean;
}
const AreaChart = <
@@ -48,7 +49,7 @@ const AreaChart = <
showXAxis,
showYAxis,
startEndOnly,
...boxProps
className,
}: AreaChartProps<TData, TIndexKey>) => {
const id = useId();
@@ -59,7 +60,7 @@ const AreaChart = <
const indexType = index.type || "date";
return (
<Box h="full" w="full" {...boxProps}>
<div className={cn("h-full w-full", className)}>
<ResponsiveContainer width="100%" height="100%">
<RechartsAreaChart data={data}>
<defs>
@@ -138,13 +139,10 @@ const AreaChart = <
})
: payload
}
style={{
fontSize: "12px",
fontFamily: "var(--chakra-fonts-body)",
}}
stroke="var(--chakra-colors-paragraph)"
className="text-xs font-sans"
stroke="hsl(var(--muted-foreground))"
tickLine={false}
axisLine={{ stroke: "var(--chakra-colors-borderColor)" }}
axisLine={{ stroke: "hsl(var(--border))" }}
interval="preserveStartEnd"
minTickGap={5}
domain={["dataMin", "dataMax"]}
@@ -166,10 +164,7 @@ const AreaChart = <
? category.format(payload)
: payload.toString();
}}
style={{
fontSize: "12px",
fontFamily: "var(--chakra-fonts-body)",
}}
className="text-xs font-sans"
domain={([dataMin, dataMax]) => [
// start from 0 unless dataMin is below 0 in which case start from dataMin - 10%
Math.min(0, dataMin - Math.round(dataMin * 0.1)),
@@ -178,14 +173,14 @@ const AreaChart = <
]}
tick={{ transform: "translate(-3, 0)" }}
type="number"
stroke="var(--chakra-colors-paragraph)"
stroke="hsl(var(--muted-foreground))"
tickLine={false}
axisLine={{ stroke: "var(--chakra-colors-borderColor)" }}
axisLine={{ stroke: "hsl(var(--border))" }}
interval="preserveStartEnd"
/>
</RechartsAreaChart>
</ResponsiveContainer>
</Box>
</div>
);
};
@@ -204,8 +199,8 @@ function generateFakeData() {
return data;
}
export const AreaChartLoadingState: React.FC = () => {
const [loadingData, setLoadingData] = useState(generateFakeData());
export const AreaChartLoadingState = (props: { height?: string }) => {
const [loadingData, setLoadingData] = useState(() => generateFakeData());
// legitimate use case
// eslint-disable-next-line no-restricted-syntax
@@ -216,26 +211,21 @@ export const AreaChartLoadingState: React.FC = () => {
return () => clearInterval(interval);
}, []);
return (
<Box position="relative">
<Center
backdropFilter="blur(4px)"
zIndex={1}
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
>
<Text size="label.lg" color="faded">
Loading Chart...
</Text>
</Center>
<div
className="relative w-full"
style={{
height: props.height,
}}
>
<div className="flex items-center justify-center filter backdrop-blur-sm z-10 absolute inset-0">
<p className="text-secondary-foreground">Loading Chart</p>
</div>
<AreaChart
pointerEvents="none"
className="pointer-events-none"
data={loadingData}
index={{ id: "key" }}
categories={[{ id: "value", color: "var(--chakra-colors-faded)" }]}
categories={[{ id: "value", color: "hsl(var(--muted-foreground))" }]}
/>
</Box>
</div>
);
};
@@ -1,4 +1,4 @@
import { Box, type BoxProps, useColorMode } from "@chakra-ui/react";
import { useTheme } from "next-themes";
import { useId, useMemo, useState } from "react";
import {
Bar,
@@ -17,7 +17,7 @@ type IndexType = "date";
interface AutoBarChartProps<
TData extends GenericDataType,
TIndexKey extends keyof TData,
> extends BoxProps {
> {
data: TData[];
index: {
id: TIndexKey;
@@ -81,19 +81,18 @@ export const AutoBarChart = <
showXAxis,
showYAxis,
stacked,
...boxProps
}: AutoBarChartProps<TData, TIndexKey>) => {
const { colorMode } = useColorMode();
const { theme } = useTheme();
const [hoverKey, setHoverKey] = useState("");
const id = useId();
const barColors = useMemo(() => {
if (colorMode === "light") {
if (theme === "light") {
return BAR_COLORS_LIGHT;
}
return BAR_COLORS_DARK;
}, [colorMode]);
}, [theme]);
const categories = useMemo(() => {
const autoKeys: string[] = [];
@@ -117,8 +116,6 @@ export const AutoBarChart = <
}));
}, [data, index.id, barColors]);
const indexType = index.type || "date";
const sortedData = useMemo(() => {
return [...data].sort(
(a, b) => (a[index.id] as number) - (b[index.id] as number),
@@ -130,126 +127,121 @@ export const AutoBarChart = <
}
return (
<Box {...boxProps}>
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={data}>
<defs>
{categories.map((cat) => (
<linearGradient
key={`${cat.id as string}`}
id={`bar_color_${id}_${cat.id as string}`}
x1="0"
y1="0"
x2="0"
y2="100%"
gradientUnits="userSpaceOnUse"
>
<stop
offset="0"
stopColor={cat.color || "#3385FF"}
stopOpacity={1}
/>
<stop
offset="1"
stopColor={cat.color || "#3385FF"}
stopOpacity={0.75}
/>
</linearGradient>
))}
</defs>
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={data}>
<defs>
{categories.map((cat) => (
<Bar
<linearGradient
key={`${cat.id as string}`}
dataKey={cat.id as string}
stackId={stacked ? "a" : (cat.id as string)}
stroke={cat.color || "#3385FF"}
fill={`url(#bar_color_${id}_${cat.id as string})`}
opacity={cat.id === hoverKey || !hoverKey ? 1 : 0.5}
strokeWidth={0}
onMouseOver={(item) => {
setHoverKey(item.tooltipPayload[0].dataKey);
}}
onMouseLeave={() => {
setHoverKey("");
}}
/>
id={`bar_color_${id}_${cat.id as string}`}
x1="0"
y1="0"
x2="0"
y2="100%"
gradientUnits="userSpaceOnUse"
>
<stop
offset="0"
stopColor={cat.color || "#3385FF"}
stopOpacity={1}
/>
<stop
offset="1"
stopColor={cat.color || "#3385FF"}
stopOpacity={0.75}
/>
</linearGradient>
))}
<Tooltip
wrapperStyle={{ outline: "none" }}
content={({ active, payload }) => {
if (!active || !payload) {
return null;
}
</defs>
const { time, ...values } = payload[0].payload;
return (
<StackToolTip time={time} values={values} hoverKey={hoverKey} />
);
{categories.map((cat) => (
<Bar
key={`${cat.id as string}`}
dataKey={cat.id as string}
stackId={stacked ? "a" : (cat.id as string)}
stroke={cat.color || "#3385FF"}
fill={`url(#bar_color_${id}_${cat.id as string})`}
opacity={cat.id === hoverKey || !hoverKey ? 1 : 0.5}
strokeWidth={0}
onMouseOver={(item) => {
setHoverKey(item.tooltipPayload[0].dataKey);
}}
cursor={
!hoverKey || categories.length <= 1
? {
stroke: "#EAEAEA",
fill: "#EAEAEA",
opacity: 0.3,
strokeDasharray: 2,
strokeWidth: 0,
}
: false
onMouseLeave={() => {
setHoverKey("");
}}
/>
))}
<Tooltip
wrapperStyle={{ outline: "none" }}
content={({ active, payload }) => {
if (!active || !payload) {
return null;
}
/>
<XAxis
hide={!showXAxis}
dataKey={index.id as string}
tickFormatter={(payload) =>
index.format
? index.format(payload)
: indexType === "date"
? new Date(payload).toLocaleDateString(undefined, {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})
: payload
}
style={{
fontSize: "12px",
fontFamily: "var(--chakra-fonts-body)",
}}
stroke="var(--chakra-colors-paragraph)"
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={5}
domain={["dataMin - 86400000", "dataMax + 86400000"]}
type="number"
tick={{ transform: "translate(0, 6)" }}
ticks={[
sortedData[0][index.id],
sortedData[Math.floor(sortedData.length / 2)][index.id],
sortedData[sortedData.length - 1][index.id],
]}
/>
const { time, ...values } = payload[0].payload;
return (
<StackToolTip time={time} values={values} hoverKey={hoverKey} />
);
}}
cursor={
!hoverKey || categories.length <= 1
? {
stroke: "#EAEAEA",
fill: "#EAEAEA",
opacity: 0.3,
strokeDasharray: 2,
strokeWidth: 0,
}
: false
}
/>
<YAxis
hide={!showYAxis}
width={60}
tickFormatter={(payload) => {
return payload.toLocaleString();
}}
style={{
fontSize: "12px",
fontFamily: "var(--chakra-fonts-body)",
}}
type="number"
stroke="var(--chakra-colors-paragraph)"
tickLine={false}
axisLine={false}
/>
</RechartsBarChart>
</ResponsiveContainer>
</Box>
<XAxis
hide={!showXAxis}
dataKey={index.id as string}
tickFormatter={(payload) =>
index.format
? index.format(payload)
: index.type === "date"
? new Date(payload).toLocaleDateString(undefined, {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})
: payload
}
style={{
fontSize: "12px",
fontFamily: "var(--chakra-fonts-body)",
}}
stroke="var(--chakra-colors-paragraph)"
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={5}
domain={["dataMin - 86400000", "dataMax + 86400000"]}
type="number"
tick={{ transform: "translate(0, 6)" }}
ticks={[
sortedData[0][index.id],
sortedData[Math.floor(sortedData.length / 2)][index.id],
sortedData[sortedData.length - 1][index.id],
]}
/>
<YAxis
hide={!showYAxis}
width={60}
tickFormatter={(payload) => {
return payload.toLocaleString();
}}
className="text-xs font-sans"
type="number"
stroke="hsl(var(--muted-foreground))"
tickLine={false}
axisLine={false}
/>
</RechartsBarChart>
</ResponsiveContainer>
);
};
@@ -1,4 +1,4 @@
import { Box, type BoxProps, useColorMode } from "@chakra-ui/react";
import { useTheme } from "next-themes";
import { useId, useMemo } from "react";
import {
Bar,
@@ -17,7 +17,7 @@ type IndexType = "date";
interface BarChartProps<
TData extends GenericDataType,
TIndexKey extends keyof TData,
> extends BoxProps {
> {
data: TData[];
index: {
id: TIndexKey;
@@ -46,140 +46,132 @@ export const BarChart = <
categories,
showXAxis,
showYAxis,
...boxProps
}: BarChartProps<TData, TIndexKey>) => {
const { colorMode } = useColorMode();
const { theme } = useTheme();
const id = useId();
const defaultBarColor = useMemo(() => {
if (colorMode === "light") {
if (theme === "light") {
return "#649CDD";
}
return "#3682DA";
}, [colorMode]);
}, [theme]);
if (!data.length) {
return null;
}
const indexType = index.type || "date";
return (
<Box {...boxProps}>
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={data}>
<defs>
{categories.map((cat) => (
<linearGradient
key={`${cat.id as string}`}
id={`bar_color_${id}_${cat.id as string}`}
x1="0"
y1="0"
x2="0"
y2="100%"
gradientUnits="userSpaceOnUse"
>
<stop
offset="0"
stopColor={cat.color || defaultBarColor}
stopOpacity={1}
/>
<stop
offset="1"
stopColor={cat.color || defaultBarColor}
stopOpacity={0.75}
/>
</linearGradient>
))}
</defs>
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={data}>
<defs>
{categories.map((cat) => (
<Bar
<linearGradient
key={`${cat.id as string}`}
dataKey={cat.id as string}
stackId="a"
stroke={cat.color || defaultBarColor}
fill={`url(#bar_color_${id}_${cat.id as string})`}
strokeWidth={0}
/>
id={`bar_color_${id}_${cat.id as string}`}
x1="0"
y1="0"
x2="0"
y2="100%"
gradientUnits="userSpaceOnUse"
>
<stop
offset="0"
stopColor={cat.color || defaultBarColor}
stopOpacity={1}
/>
<stop
offset="1"
stopColor={cat.color || defaultBarColor}
stopOpacity={0.75}
/>
</linearGradient>
))}
<Tooltip
wrapperStyle={{ outline: "none" }}
content={({ active, payload }) => {
const payloadKey = payload?.[0]?.dataKey;
const category = categories.find((cat) => cat.id === payloadKey);
return (
<CustomToolTip
active={active}
payload={payload}
valueLabel={category?.label || ""}
valueFormatter={category?.format}
/>
);
}}
cursor={{
stroke: defaultBarColor,
fill: defaultBarColor,
opacity: 0.2,
strokeDasharray: 2,
strokeWidth: 0,
}}
/>
</defs>
<XAxis
hide={!showXAxis}
dataKey={index.id as string}
tickFormatter={(payload) =>
index.format
? index.format(payload)
: indexType === "date"
? new Date(payload).toLocaleDateString(undefined, {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})
: payload
}
style={{
fontSize: "12px",
fontFamily: "var(--chakra-fonts-body)",
}}
stroke="var(--chakra-colors-paragraph)"
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={5}
domain={["dataMin - 86400000", "dataMax + 86400000"]}
type="number"
tick={{ transform: "translate(0, 6)" }}
ticks={[
data[0][index.id],
data[Math.floor(data.length / 2)][index.id],
data[data.length - 1][index.id],
]}
{categories.map((cat) => (
<Bar
key={`${cat.id as string}`}
dataKey={cat.id as string}
stackId="a"
stroke={cat.color || defaultBarColor}
fill={`url(#bar_color_${id}_${cat.id as string})`}
strokeWidth={0}
/>
))}
<Tooltip
wrapperStyle={{ outline: "none" }}
content={({ active, payload }) => {
const payloadKey = payload?.[0]?.dataKey;
const category = categories.find((cat) => cat.id === payloadKey);
return (
<CustomToolTip
active={active}
payload={payload}
valueLabel={category?.label || ""}
valueFormatter={category?.format}
/>
);
}}
cursor={{
stroke: defaultBarColor,
fill: defaultBarColor,
opacity: 0.2,
strokeDasharray: 2,
strokeWidth: 0,
}}
/>
<YAxis
hide={!showYAxis}
width={60}
tickFormatter={(payload) => {
const category = categories[0];
return category?.format
? category.format(payload)
: payload.toLocaleString();
}}
style={{
fontSize: "12px",
fontFamily: "var(--chakra-fonts-body)",
}}
type="number"
stroke="var(--chakra-colors-paragraph)"
tickLine={false}
axisLine={false}
/>
</RechartsBarChart>
</ResponsiveContainer>
</Box>
<XAxis
hide={!showXAxis}
dataKey={index.id as string}
tickFormatter={(payload) =>
index.format
? index.format(payload)
: index.type === "date"
? new Date(payload).toLocaleDateString(undefined, {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})
: payload
}
style={{
fontSize: "12px",
fontFamily: "var(--chakra-fonts-body)",
}}
stroke="var(--chakra-colors-paragraph)"
tickLine={false}
axisLine={false}
interval="preserveStartEnd"
minTickGap={5}
domain={["dataMin - 86400000", "dataMax + 86400000"]}
type="number"
tick={{ transform: "translate(0, 6)" }}
ticks={[
data[0][index.id],
data[Math.floor(data.length / 2)][index.id],
data[data.length - 1][index.id],
]}
/>
<YAxis
hide={!showYAxis}
width={60}
tickFormatter={(payload) => {
const category = categories[0];
return category?.format
? category.format(payload)
: payload.toLocaleString();
}}
className="font-sans text-xs"
stroke="hsl(var(--muted-foreground))"
type="number"
tickLine={false}
axisLine={false}
/>
</RechartsBarChart>
</ResponsiveContainer>
);
};
@@ -1,31 +1,35 @@
import { AspectRatio, type AspectRatioProps, Flex } from "@chakra-ui/react";
import { Suspense } from "react";
import type { ComponentWithChildren } from "types/component-with-children";
import { AspectRatio } from "../../@/components/ui/aspect-ratio";
import { cn } from "../../@/lib/utils";
import { AreaChartLoadingState } from "./area-chart";
type SupportedChartType = "area";
interface ChartContainerProps extends AspectRatioProps {
interface ChartContainerProps {
chartTitle?: JSX.Element;
chartType?: SupportedChartType;
ratio: number;
className?: string;
}
export const ChartContainer: ComponentWithChildren<ChartContainerProps> = ({
chartType = "area",
chartTitle,
ratio,
children,
...aspectRatioProps
className,
}) => {
return (
<Flex direction="column">
<div className={cn("flex flex-col", className)}>
{chartTitle}
<AspectRatio {...aspectRatioProps}>
<AspectRatio ratio={ratio}>
{/* suspense to handle loading state */}
<Suspense fallback={<ChartLoadingState chartType={chartType} />}>
{children}
</Suspense>
</AspectRatio>
</Flex>
</div>
);
};
@@ -1,6 +1,3 @@
import { Flex } from "@chakra-ui/react";
import { Heading, Text } from "tw-components";
type CustomToolTipProps = {
valueLabel: string;
active?: boolean;
@@ -24,47 +21,31 @@ export const CustomToolTip: React.FC<CustomToolTipProps> = ({
}) => {
if (active && payload && payload.length) {
return (
<Flex
py={1.5}
px={2.5}
backdropFilter="blur(10px)"
bg="transparent"
flexDirection="column"
gap={1}
border="none"
outline="none"
borderRadius="lg"
_dark={{
bg: "rgba(0,0,0,0.2)",
}}
_light={{
bg: "rgba(255,255,255,0.2)",
}}
>
<div className="flex flex-col p-3 gap-2 border-none outline-none rounded-lg backdrop-blur-lg bg-[rgba(255,255,255,0.2)] dark:bg-[rgba(0,0,0,0.2)] ">
{payload[0]?.payload?.time && (
<Flex direction="column" gap={0.5}>
<Heading as="label" size="label.sm">
<div className="flex flex-col gap-0.5">
<label className="text-xs font-semibold text-foreground">
Date
</Heading>
<Text size="body.sm">
</label>
<p className="text-xs">
{new Date(payload[0].payload.time).toLocaleDateString(
undefined,
formattingOptions,
)}
</Text>
</Flex>
</p>
</div>
)}
<Flex direction="column" gap={0.5}>
<Heading as="label" size="label.sm">
<div className="flex flex-col gap-0.5">
<label className="text-xs font-semibold text-foreground">
{valueLabel}
</Heading>
<Text size="body.sm" fontFamily="mono">
</label>
<p className="text-xs font-mono">
{valueFormatter
? valueFormatter(payload[0].value)
: payload[0].value.toLocaleString()}
</Text>
</Flex>
</Flex>
</p>
</div>
</div>
);
}
@@ -1,5 +1,4 @@
import { Flex, useColorMode } from "@chakra-ui/react";
import { Heading, Text } from "tw-components";
import { cn } from "../../@/lib/utils";
type CustomToolTipProps = {
time: string;
@@ -18,44 +17,25 @@ export const StackToolTip: React.FC<CustomToolTipProps> = ({
values,
hoverKey,
}) => {
const { colorMode } = useColorMode();
return (
<Flex
py={1.5}
px={2.5}
backdropFilter="blur(10px)"
bg="transparent"
flexDirection="column"
gap={1}
border="none"
outline="none"
borderRadius="lg"
_dark={{
bg: "rgba(0,0,0,0.2)",
}}
_light={{
bg: "rgba(255,255,255,0.2)",
}}
>
<Flex direction="column" gap={0.5}>
<Heading as="label" size="label.sm">
Date
</Heading>
<Text size="body.sm">
<div className="flex flex-col p-3 gap-2 border-none outline-none rounded-lg backdrop-blur-lg bg-[rgba(255,255,255,0.2)] dark:bg-[rgba(0,0,0,0.2)] ">
<div className="flex flex-col gap-0.5">
<label className="text-xs font-semibold text-foreground">Date</label>
<p className="text-xs">
{new Date(time).toLocaleDateString(undefined, formattingOptions)}
</Text>
</Flex>
</p>
</div>
<Flex direction="column" gap={0.5}>
<div className="flex flex-col gap-0.5">
{Object.entries(values)
.reverse()
.map(([key, value]) => (
<Text
<p
className={cn(
"text-xs",
hoverKey && key !== hoverKey ? "opacity-70" : "opacity-100",
)}
key={key}
fontSize="12px"
color={colorMode === "dark" ? "white" : "#333"}
opacity={hoverKey && key !== hoverKey ? 0.8 : 1}
>
{hoverKey && key !== hoverKey ? (
`${key}: `
@@ -63,9 +43,9 @@ export const StackToolTip: React.FC<CustomToolTipProps> = ({
<strong>{key}: </strong>
)}
{value.toLocaleString()}
</Text>
</p>
))}
</Flex>
</Flex>
</div>
</div>
);
};
@@ -50,13 +50,14 @@ export const AppShell: ComponentWithChildren<AppShellProps> = ({
<GridItem
id={SIDEBAR_TUNNEL_ID}
bg="backgroundHighlight"
colSpan={{ base: 2, md: 1 }}
rowSpan={{ base: 1, md: 2 }}
as="aside"
position="sticky"
top={0}
zIndex="sticky"
borderRight="1px solid"
borderColor="borderColor"
boxShadow="sm"
w={{ md: hasSidebar ? SIDEBAR_WIDTH : "auto" }}
>
@@ -94,9 +95,10 @@ const AppHeader: React.FC = () => {
<GridItem
colSpan={{ base: 2, md: 2 }}
rowSpan={1}
background="backgroundHighlight"
zIndex="sticky"
boxShadow="sm"
borderBottom="1px solid"
borderColor="borderColor"
pb={2}
>
<Container
@@ -0,0 +1,214 @@
import type { ApiKey } from "@3rdweb-sdk/react/hooks/useApi";
import { format } from "date-fns";
import { useState } from "react";
import { DatePickerWithRange } from "../../../@/components/ui/DatePickerWithRange";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../@/components/ui/select";
import { PayCustomersTable } from "./components/PayCustomersTable";
import { PayNewCustomers } from "./components/PayNewCustomers";
import { PaymentHistory } from "./components/PaymentHistory";
import { PaymentsSuccessRate } from "./components/PaymentsSuccessRate";
import { Payouts } from "./components/Payouts";
import { TotalPayVolume } from "./components/TotalPayVolume";
import { TotalVolumePieChart } from "./components/TotalVolumePieChart";
const durationPresets = [
{
name: "Last 7 Days",
id: "last-7",
days: 7,
},
{
name: "Last 30 Days",
id: "last-30",
days: 30,
},
{
name: "Last 60 Days",
id: "last-60",
days: 60,
},
{
name: "Last 120 Days",
id: "last-120",
days: 120,
},
] as const;
type DurationId = (typeof durationPresets)[number]["id"];
type Range = {
type: DurationId | "custom";
label?: string;
from: Date;
to: Date;
};
export function PayAnalytics(props: { apiKey: ApiKey }) {
const clientId = props.apiKey.key;
const [range, setRange] = useState<Range>(() =>
getLastNDaysRange("last-120"),
);
const numberOfDays = Math.round(
(range.to.getTime() - range.from.getTime()) / (1000 * 60 * 60 * 24),
);
return (
<div>
<div className="flex mb-2">
<Filters range={range} setRange={setRange} />
</div>
<div className="flex flex-col gap-10 lg:gap-4">
<GridWithSeparator>
<div className="border-b border-border pb-6 xl:pb-0 xl:border-none flex items-center">
<TotalVolumePieChart
clientId={clientId}
from={range.from}
to={range.to}
/>
</div>
<TotalPayVolume
clientId={clientId}
from={range.from}
to={range.to}
numberOfDays={numberOfDays}
/>
</GridWithSeparator>
<div className="grid gap-4 grid-cols-1 xl:grid-cols-2 ">
<div className="border border-border rounded-xl p-4 xl:p-6">
<Payouts
clientId={clientId}
from={range.from}
to={range.to}
numberOfDays={numberOfDays}
/>
</div>
<div className="border border-border rounded-xl p-4 xl:p-6 flex ">
<PaymentsSuccessRate
clientId={clientId}
from={range.from}
to={range.to}
/>
</div>
</div>
<GridWithSeparator>
<div className="border-b border-border pb-6 xl:pb-0 xl:border-none">
<PayNewCustomers
clientId={clientId}
from={range.from}
to={range.to}
numberOfDays={numberOfDays}
/>
</div>
<PayCustomersTable
clientId={clientId}
from={range.from}
to={range.to}
/>
</GridWithSeparator>
<div className="border border-border rounded-xl p-4 xl:p-6">
<PaymentHistory clientId={clientId} from={range.from} to={range.to} />
</div>
</div>
</div>
);
}
function getLastNDaysRange(id: DurationId) {
const todayDate = new Date();
const pastDate = new Date(todayDate);
const durationInfo = durationPresets.find((preset) => preset.id === id);
if (!durationInfo) {
throw new Error("Invalid duration id");
}
pastDate.setDate(todayDate.getDate() - durationInfo.days);
const value: Range = {
type: id,
from: pastDate,
to: todayDate,
label: durationInfo.name,
};
return value;
}
function Filters(props: { range: Range; setRange: (range: Range) => void }) {
const { range, setRange } = props;
const presets = (
<div className="p-4 border-b border-border mb-2">
<Select
value={range.type}
onValueChange={(id: DurationId) => {
setRange(getLastNDaysRange(id));
}}
>
<SelectTrigger className="bg-transparent flex">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
{durationPresets.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
{range.type === "custom" && (
<SelectItem value="custom">
{format(range.from, "LLL dd, y")} -{" "}
{format(range.to, "LLL dd, y")}
</SelectItem>
)}
</SelectContent>
</Select>
</div>
);
return (
<div className="flex gap-2">
<DatePickerWithRange
from={range.from}
to={range.to}
setFrom={(from) =>
setRange({
from,
to: range.to,
type: "custom",
})
}
setTo={(to) =>
setRange({
from: range.from,
to,
type: "custom",
})
}
header={presets}
labelOverride={range.label}
className="p-0 border-none w-auto"
/>
</div>
);
}
function GridWithSeparator(props: { children: React.ReactNode }) {
return (
<div className="p-4 xl:p-6 relative border border-border grid gap-6 lg:gap-12 grid-cols-1 xl:grid-cols-2 rounded-xl">
{props.children}
{/* Desktop - horizontal middle */}
<div className="absolute left-[50%] w-[1px] top-6 bottom-6 bg-border hidden xl:block" />
</div>
);
}
@@ -0,0 +1,72 @@
import { Spinner } from "@/components/ui/Spinner/Spinner";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import { useMutation } from "@tanstack/react-query";
import { DownloadIcon } from "lucide-react";
export function ExportToCSVButton(props: {
getData: () => Promise<{ header: string[]; rows: string[][] }>;
fileName: string;
}) {
const exportMutation = useMutation({
mutationFn: async () => {
const data = await props.getData();
exportToCSV(props.fileName, data);
},
onError: () => {
toast({
title: "Failed to download CSV",
variant: "destructive",
});
},
onSuccess: () => {
toast({
title: "Exported Successfully",
description: "CSV file has been downloaded",
});
},
});
const { toast } = useToast();
return (
<Button
variant="outline"
className="border flex gap-2 items-center text-xs"
onClick={async () => {
exportMutation.mutate();
}}
>
{exportMutation.isLoading ? (
<>
Downloading
<Spinner className="size-3" />
</>
) : (
<>
<DownloadIcon className="size-3" />
Export as CSV
</>
)}
</Button>
);
}
function exportToCSV(
fileName: string,
data: { header: string[]; rows: string[][] },
) {
const { header, rows } = data;
const csvContent = `data:text/csv;charset=utf-8,${header.join(",")}\n${rows
.map((e) => e.join(","))
.join("\n")}`;
const encodedUri = encodeURI(csvContent);
const link = document.createElement("a");
link.setAttribute("href", encodedUri);
link.setAttribute("download", `${fileName}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
@@ -0,0 +1,235 @@
import { useState } from "react";
import { CopyAddressButton } from "../../../../@/components/ui/CopyAddressButton";
import { ScrollShadow } from "../../../../@/components/ui/ScrollShadow/ScrollShadow";
import { Button } from "../../../../@/components/ui/button";
import { SkeletonContainer } from "../../../../@/components/ui/skeleton";
import {
type PayTopCustomersData,
usePayCustomers,
} from "../hooks/usePayCustomers";
import { ExportToCSVButton } from "./ExportToCSVButton";
import {
NoDataAvailable,
TableData,
TableHeading,
TableHeadingRow,
} from "./common";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type UIData = {
customers: Array<{
walletAddress: string;
totalSpendUSDCents: number;
}>;
showLoadMore: boolean;
};
function getUIData(topCustomersQuery: ReturnType<typeof usePayCustomers>): {
data?: UIData;
isError?: boolean;
isLoading?: boolean;
} {
if (topCustomersQuery.isLoading) {
return { isLoading: true };
}
if (topCustomersQuery.isError) {
return { isError: true };
}
let customers = topCustomersQuery.data.pages.flatMap(
(x) => x.pageData.customers,
);
customers = customers.filter((x) => x.totalSpendUSDCents > 0);
if (customers.length === 0) {
return { isError: true };
}
return {
data: {
customers,
showLoadMore: !!topCustomersQuery.hasNextPage,
},
};
}
export function PayCustomersTable(props: {
clientId: string;
from: Date;
to: Date;
}) {
const [type, setType] = useState<"top-customers" | "new-customers">(
"top-customers",
);
const topCustomersQuery = usePayCustomers({
clientId: props.clientId,
from: props.from,
to: props.to,
pageSize: 100,
type,
});
const uiData = getUIData(topCustomersQuery);
const customersData = uiData.data?.customers;
return (
<div className="flex flex-col">
{/* header */}
<div className="flex flex-col lg:flex-row lg:justify-between gap-2 lg:items-center">
<Select
value={type}
onValueChange={(value: "top-customers" | "new-customers") => {
setType(value);
}}
>
<SelectTrigger className="bg-transparent w-auto border-none p-0 text-base focus:ring-transparent">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="top-customers">
{" "}
Top Customers By Spend{" "}
</SelectItem>
<SelectItem value="new-customers"> New Customers </SelectItem>
</SelectContent>
</Select>
{customersData && (
<ExportToCSVButton
fileName="top_customers"
getData={async () => {
return getCSVData(customersData);
}}
/>
)}
</div>
{!uiData.isError ? (
<>
<div className="h-5" />
<RenderData
data={uiData.data}
loadMore={() => {
topCustomersQuery.fetchNextPage();
}}
/>
</>
) : (
<NoDataAvailable />
)}
</div>
);
}
function RenderData(props: { data?: UIData; loadMore: () => void }) {
return (
<ScrollShadow scrollableClassName="h-[250px]" disableTopShadow={true}>
<table className="w-full">
<thead>
<TableHeadingRow>
<TableHeading> Wallet Address </TableHeading>
<TableHeading> Total spend </TableHeading>
</TableHeadingRow>
</thead>
<tbody>
{props.data ? (
props.data?.customers.map((customer, i) => {
return (
<CustomerTableRow
key={customer.walletAddress}
customer={customer}
rowIndex={i}
/>
);
})
) : (
<>
{new Array(5).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<CustomerTableRow rowIndex={i} key={i} />
))}
</>
)}
</tbody>
</table>
{props.data?.showLoadMore && (
<div className="flex justify-center py-3">
<Button
className="text-sm text-link-foreground p-2 h-auto"
variant="ghost"
onClick={props.loadMore}
>
View More
</Button>
</div>
)}
</ScrollShadow>
);
}
function CustomerTableRow(props: {
customer?: {
walletAddress: string;
totalSpendUSDCents: number;
};
rowIndex: number;
}) {
const delayAnim = {
animationDelay: `${props.rowIndex * 100}ms`,
};
return (
<tr className="border-b border-border">
<TableData>
<SkeletonContainer
className="inline-flex"
style={delayAnim}
loadedData={props.customer?.walletAddress}
skeletonData="0x0000000000000000000000000000000000000000"
render={(v) => {
return (
<CopyAddressButton
address={v}
variant="ghost"
className="text-secondary-foreground"
/>
);
}}
/>
</TableData>
<TableData>
<SkeletonContainer
style={delayAnim}
className="inline-flex"
loadedData={props.customer?.totalSpendUSDCents}
skeletonData={20000}
render={(v) => {
return <p>${(v / 100).toLocaleString()}</p>;
}}
/>
</TableData>
</tr>
);
}
function getCSVData(data: PayTopCustomersData["customers"]) {
const header = ["Wallet Address", "Total spend"];
const rows = data.map((customer) => [
customer.walletAddress,
`$${(customer.totalSpendUSDCents / 100).toLocaleString()}`,
]);
return { header, rows };
}
@@ -0,0 +1,208 @@
import { format } from "date-fns";
import { useEffect, useId, useState } from "react";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import { SkeletonContainer } from "../../../../@/components/ui/skeleton";
import { AreaChartLoadingState } from "../../../analytics/area-chart";
import { usePayNewCustomers } from "../hooks/usePayNewCustomers";
import {
CardHeading,
ChangeBadge,
IntervalSelector,
NoDataAvailable,
chartHeight,
} from "./common";
type GraphDataItem = {
date: string;
value: number;
};
type UIData = {
graphData: GraphDataItem[];
totalNewCustomers: number;
percentChange: number;
};
function getUIData(newCustomersQuery: ReturnType<typeof usePayNewCustomers>): {
data?: UIData;
isError?: boolean;
isLoading?: boolean;
} {
if (newCustomersQuery.isLoading) {
return { isLoading: true };
}
if (newCustomersQuery.isError) {
return { isError: true };
}
if (newCustomersQuery.data.intervalResults.length === 0) {
return { isError: true };
}
const newCusomtersData: GraphDataItem[] =
newCustomersQuery.data.intervalResults.map((x) => {
return {
date: format(new Date(x.interval), "LLL dd"),
value: x.distinctCustomers,
};
});
const totalNewCustomers = newCustomersQuery.data.aggregate.distinctCustomers;
return {
data: {
graphData: newCusomtersData,
totalNewCustomers,
percentChange:
newCustomersQuery.data.aggregate.bpsIncreaseFromPriorRange / 100,
},
};
}
export function PayNewCustomers(props: {
clientId: string;
from: Date;
to: Date;
numberOfDays: number;
}) {
const [intervalType, setIntervalType] = useState<"day" | "week">(
props.numberOfDays > 30 ? "week" : "day",
);
// if prop changes, update intervalType
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
setIntervalType(props.numberOfDays > 30 ? "week" : "day");
}, [props.numberOfDays]);
const newCustomersQuery = usePayNewCustomers({
clientId: props.clientId,
from: props.from,
to: props.to,
intervalType,
});
const uiData = getUIData(newCustomersQuery);
return (
<section className="relative flex flex-col min-h-[320px]">
{/* header */}
<div className="flex justify-between gap-2 items-center mb-1">
<CardHeading>New Customers </CardHeading>
<div className={!uiData.data ? "invisible" : ""}>
<IntervalSelector
intervalType={intervalType}
setIntervalType={setIntervalType}
/>
</div>
</div>
{/* Chart */}
{!uiData.isError ? (
<RenderData
data={uiData.data}
intervalType={intervalType}
setIntervalType={setIntervalType}
/>
) : (
<NoDataAvailable />
)}
</section>
);
}
function RenderData(props: {
data?: UIData;
intervalType: "day" | "week";
setIntervalType: (intervalType: "day" | "week") => void;
}) {
const uniqueId = useId();
return (
<div>
<div className="flex items-center gap-3 mb-5">
<SkeletonContainer
loadedData={props.data?.totalNewCustomers}
skeletonData={100}
render={(v) => {
return (
<p className="text-4xl tracking-tighter font-semibold">{v}</p>
);
}}
/>
<SkeletonContainer
loadedData={props.data?.percentChange}
className="rounded-2xl"
skeletonData={1}
render={(v) => {
return <ChangeBadge percent={v} />;
}}
/>
</div>
<div className="relative flex justify-center w-full ">
{props.data?.graphData ? (
<ResponsiveContainer width="100%" height={chartHeight}>
<AreaChart data={props.data.graphData}>
<defs>
<linearGradient id={uniqueId} x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor={"hsl(var(--link-foreground))"}
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor={"hsl(var(--link-foreground))"}
stopOpacity={0.0}
/>
</linearGradient>
</defs>
<Tooltip
content={(x) => {
const payload = x.payload?.[0]?.payload as
| GraphDataItem
| undefined;
return (
<div className="bg-popover px-4 py-2 rounded border border-border">
<p className="text-muted-foreground mb-1 text-sm">
{payload?.date}
</p>
<p className="text-medium text-base">
Customers: {payload?.value}
</p>
</div>
);
}}
/>
<Area
type="monotone"
dataKey="value"
stroke="hsl(var(--link-foreground))"
fillOpacity={1}
fill={`url(#${uniqueId})`}
strokeWidth={2}
strokeLinecap="round"
/>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
className="text-xs font-sans mt-5"
stroke="hsl(var(--muted-foreground))"
dy={12}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<AreaChartLoadingState height={`${chartHeight}px`} />
)}
</div>
</div>
);
}
@@ -0,0 +1,288 @@
import { format } from "date-fns";
import { useState } from "react";
import { PaginationButtons } from "../../../../@/components/pagination-buttons";
import { CopyAddressButton } from "../../../../@/components/ui/CopyAddressButton";
import { ScrollShadow } from "../../../../@/components/ui/ScrollShadow/ScrollShadow";
import { Badge } from "../../../../@/components/ui/badge";
import { Skeleton } from "../../../../@/components/ui/skeleton";
import { cn } from "../../../../@/lib/utils";
import {
type PayPurchasesData,
getPayPurchases,
usePayPurchases,
} from "../hooks/usePayPurchases";
import { ExportToCSVButton } from "./ExportToCSVButton";
import {
CardHeading,
NoDataAvailable,
TableData,
TableHeading,
TableHeadingRow,
} from "./common";
type UIData = {
purchases: PayPurchasesData["purchases"];
pages: number;
};
const pageSize = 10;
function getUIData(purchasesQuery: ReturnType<typeof usePayPurchases>): {
data?: UIData;
isLoading?: boolean;
isError?: boolean;
} {
if (purchasesQuery.isLoading) {
return { isLoading: true };
}
if (purchasesQuery.isError) {
return { isError: true };
}
const purchases = purchasesQuery.data.purchases;
const totalCount = purchasesQuery.data.count;
if (purchases.length === 0) {
return { isError: true };
}
return {
data: {
purchases,
pages: Math.ceil(totalCount / pageSize),
},
};
}
export function PaymentHistory(props: {
clientId: string;
from: Date;
to: Date;
}) {
const [page, setPage] = useState(1);
const purchasesQuery = usePayPurchases({
clientId: props.clientId,
from: props.from,
to: props.to,
start: (page - 1) * pageSize,
count: pageSize,
});
const uiData = getUIData(purchasesQuery);
return (
<div>
<div className="flex flex-col lg:flex-row lg:justify-between gap-2 lg:items-center">
<CardHeading> Transaction History</CardHeading>
{!uiData.isError && (
<ExportToCSVButton
fileName="transaction_history"
getData={async () => {
const purchaseData = await getPayPurchases({
clientId: props.clientId,
count: 10000,
from: props.from,
start: 0,
to: props.to,
});
return getCSVData(purchaseData.purchases);
}}
/>
)}
</div>
<div className="h-5" />
{!uiData.isError ? (
<RenderData
data={uiData.data}
activePage={page}
setPage={setPage}
isLoadingMore={purchasesQuery.isFetching}
/>
) : (
<NoDataAvailable />
)}
</div>
);
}
function RenderData(props: {
data?: UIData;
isLoadingMore: boolean;
activePage: number;
setPage: (page: number) => void;
}) {
return (
<div>
<ScrollShadow>
<table className="w-full selection:bg-inverted selection:text-inverted-foreground ">
<thead>
<TableHeadingRow>
<TableHeading> Bought </TableHeading>
<TableHeading> Paid </TableHeading>
<TableHeading>Type</TableHeading>
<TableHeading>Status</TableHeading>
<TableHeading>Recipient</TableHeading>
<TableHeading>Date</TableHeading>
</TableHeadingRow>
</thead>
<tbody>
{props.data && !props.isLoadingMore ? (
<>
{props.data.purchases.map((purchase) => {
return (
<TableRow key={purchase.purchaseId} purchase={purchase} />
);
})}
</>
) : (
<>
{new Array(pageSize).fill(0).map((_, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<SkeletonTableRow key={i} />
))}
</>
)}
</tbody>
</table>
</ScrollShadow>
<div className="h-8" />
<PaginationButtons
activePage={props.activePage}
totalPages={props.data?.pages || 5}
onPageClick={props.setPage}
/>
</div>
);
}
function TableRow(props: { purchase: PayPurchasesData["purchases"][0] }) {
const { purchase } = props;
return (
<tr
key={purchase.purchaseId}
className="border-b border-border fade-in-0 duration-300"
>
{/* Bought */}
<TableData>{`${formatTokenAmount(purchase.toAmount)} ${purchase.toToken.symbol}`}</TableData>
{/* Paid */}
<TableData>
{purchase.purchaseType === "SWAP"
? `${formatTokenAmount(purchase.fromAmount)} ${purchase.fromToken.symbol}`
: `${formatTokenAmount(`${purchase.fromAmountUSDCents / 100}`)} ${purchase.fromCurrencySymbol}`}
</TableData>
{/* Type */}
<TableData>
<Badge
variant={"secondary"}
className={cn(
"uppercase",
purchase.purchaseType === "ONRAMP"
? "bg-fuchsia-200 dark:bg-fuchsia-950 text-fuchsia-800 dark:text-fuchsia-200"
: "bg-indigo-200 dark:bg-indigo-950 text-indigo-800 dark:text-indigo-200",
)}
>
{purchase.purchaseType === "ONRAMP" ? "Fiat" : "Crypto"}
</Badge>
</TableData>
{/* Status */}
<TableData>
<Badge
variant={
purchase.status === "COMPLETED"
? "success"
: purchase.status === "PENDING"
? "warning"
: "destructive"
}
className="capitalize"
>
{purchase.status}
</Badge>
</TableData>
{/* Address */}
<TableData>
<CopyAddressButton
address={purchase.fromAddress}
variant="ghost"
className="text-secondary-foreground"
/>
</TableData>
{/* Date */}
<TableData>
<p className="min-w-[180px] lg:min-w-auto">
{format(new Date(purchase.updatedAt), "LLL dd, y h:mm a")}
</p>
</TableData>
</tr>
);
}
function SkeletonTableRow() {
return (
<tr className="border-b border-border">
<TableData>
<Skeleton className="h-7 w-20" />
</TableData>
<TableData>
<Skeleton className="h-7 w-20" />
</TableData>
<TableData>
<Skeleton className="h-7 w-20 rounded-2xl" />
</TableData>
<TableData>
<Skeleton className="h-7 w-20 rounded-2xl" />
</TableData>
<TableData>
<Skeleton className="h-7 w-[140px]" />
</TableData>
<TableData>
<Skeleton className="h-7 w-[200px]" />
</TableData>
</tr>
);
}
function getCSVData(data: PayPurchasesData["purchases"]) {
const header = ["Type", "Bought", "Paid", "Status", "Recipient", "Date"];
const rows: string[][] = data.map((purchase) => [
// bought
`${formatTokenAmount(purchase.toAmount)} ${purchase.toToken.symbol}`,
// paid
purchase.purchaseType === "SWAP"
? `${formatTokenAmount(purchase.fromAmount)} ${purchase.fromToken.symbol}`
: `${formatTokenAmount(`${purchase.fromAmountUSDCents / 100}`)} ${purchase.fromCurrencySymbol}`,
// type
purchase.purchaseType === "ONRAMP" ? "Fiat" : "Crypto",
// status
purchase.status,
// recipient
purchase.fromAddress,
// date
format(new Date(purchase.updatedAt), "LLL dd y h:mm a"),
]);
return { header, rows };
}
function formatTokenAmount(value: string) {
// have at max 3 decimal places
const strValue = Number(`${Number(value).toFixed(3)}`);
if (Number(strValue) === 0) {
return "~0";
}
return strValue;
}
@@ -0,0 +1,200 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useState } from "react";
import { SkeletonContainer } from "../../../../@/components/ui/skeleton";
import { ToolTipLabel } from "../../../../@/components/ui/tooltip";
import { usePayVolume } from "../hooks/usePayVolume";
import { CardHeading, NoDataAvailable } from "./common";
type PayVolumeType = "all" | "crypto" | "fiat";
type UIData = {
succeeded: number;
failed: number;
rate: number;
total: number;
};
function getUIData(
volumeQuery: ReturnType<typeof usePayVolume>,
type: PayVolumeType,
): {
data?: UIData;
isError?: boolean;
isLoading?: boolean;
} {
if (volumeQuery.isLoading) {
return { isLoading: true };
}
if (volumeQuery.isError) {
return { isError: true };
}
const aggregated = volumeQuery.data.aggregate;
let succeeded = 0;
let failed = 0;
switch (type) {
case "all": {
succeeded = aggregated.sum.succeeded.count;
failed = aggregated.sum.failed.count;
break;
}
case "crypto": {
succeeded = aggregated.buyWithCrypto.succeeded.count;
failed = aggregated.buyWithCrypto.failed.count;
break;
}
case "fiat": {
succeeded = aggregated.buyWithFiat.succeeded.count;
failed = aggregated.buyWithFiat.failed.count;
break;
}
default: {
throw new Error("Invalid tab");
}
}
const total = succeeded + failed;
if (total === 0) {
return {
isError: true,
};
}
const rate = (succeeded / (succeeded + failed)) * 100;
const data = { succeeded, failed, rate, total };
return { data };
}
export function PaymentsSuccessRate(props: {
clientId: string;
from: Date;
to: Date;
}) {
const [type, setType] = useState<PayVolumeType>("all");
const volumeQuery = usePayVolume({
clientId: props.clientId,
from: props.from,
to: props.to,
intervalType: "day",
});
const uiData = getUIData(volumeQuery, type);
return (
<div className="w-full relative flex flex-col">
<div className="flex justify-between gap-2 items-center">
<CardHeading> Payments </CardHeading>
{uiData.data && (
<Select
value={type}
onValueChange={(value: PayVolumeType) => {
setType(value);
}}
>
<SelectTrigger className="bg-transparent w-auto">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">Total</SelectItem>
<SelectItem value="crypto">Crypto</SelectItem>
<SelectItem value="fiat">Fiat</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="flex-1 flex flex-col justify-center">
{!uiData.isError ? (
<RenderData data={uiData.data} />
) : (
<NoDataAvailable />
)}
</div>
</div>
);
}
function RenderData(props: { data?: UIData }) {
return (
<div>
<div className="h-10" />
<SkeletonContainer
loadedData={props.data?.rate}
skeletonData={50}
render={(rate) => <Bar rate={rate} />}
/>
<div className="h-6" />
<InfoRow
label="Succeeded"
type="success"
amount={props.data?.succeeded}
/>
<div className="h-3" />
<InfoRow label="Failed" type="failure" amount={props.data?.failed} />
</div>
);
}
function Bar(props: { rate: number }) {
return (
<div className="flex items-center gap-0.5">
<ToolTipLabel label="Succeeded">
<div
className="h-5 bg-success-foreground transition-all rounded-lg rounded-r-none border-r-0"
style={{
width: `${props.rate}%`,
}}
/>
</ToolTipLabel>
<ToolTipLabel label="Failed">
<div className="h-5 bg-destructive-foreground flex-1 transition-all rounded-lg rounded-l-none border-l-0" />
</ToolTipLabel>
</div>
);
}
function InfoRow(props: {
label: string;
type: "success" | "failure";
amount?: number;
}) {
return (
<div className="flex justify-between">
<div className="flex items-center gap-2">
<div
className={`size-5 rounded-lg ${
props.type === "success"
? "bg-success-foreground"
: "bg-destructive-foreground"
}`}
/>
<p className="text-base text-secondary-foreground">{props.label}</p>
</div>
<SkeletonContainer
loadedData={props.amount}
skeletonData={50}
render={(v) => {
return <p className="text-base font-medium">${v.toLocaleString()}</p>;
}}
/>
</div>
);
}
@@ -0,0 +1,193 @@
import { useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import { SkeletonContainer } from "../../../../@/components/ui/skeleton";
import { AreaChartLoadingState } from "../../../analytics/area-chart";
import { usePayVolume } from "../hooks/usePayVolume";
import {
CardHeading,
ChangeBadge,
IntervalSelector,
NoDataAvailable,
chartHeight,
} from "./common";
type GraphData = {
date: string;
value: number;
};
type UIQueryData = {
totalPayoutsUSD: number;
graphData: GraphData[];
percentChange: number;
};
export function Payouts(props: {
clientId: string;
from: Date;
to: Date;
numberOfDays: number;
}) {
const [intervalType, setIntervalType] = useState<"day" | "week">(
props.numberOfDays > 30 ? "week" : "day",
);
// if prop changes, update intervalType
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
setIntervalType(props.numberOfDays > 30 ? "week" : "day");
}, [props.numberOfDays]);
const payoutsQuery = usePayVolume({
clientId: props.clientId,
from: props.from,
to: props.to,
intervalType,
});
const uiQuery = useQuery<UIQueryData>({
queryKey: ["Payouts", payoutsQuery.data],
enabled: !!payoutsQuery.data,
retry: payoutsQuery.isError || false,
queryFn: async () => {
if (!payoutsQuery.data) {
throw new Error("Unexpected");
}
if (payoutsQuery.data.intervalResults.length === 0) {
throw new Error("No data available");
}
const totalPayoutsUSD =
payoutsQuery.data.aggregate.payouts.amountUSDCents / 100;
const graphData: GraphData[] = payoutsQuery.data.intervalResults.map(
(result) => ({
date: format(new Date(result.interval), "LLL dd"),
value: result.payouts.amountUSDCents / 100,
}),
);
const percentChange =
payoutsQuery.data.aggregate.payouts.bpsIncreaseFromPriorRange / 100;
return { totalPayoutsUSD, graphData, percentChange };
},
});
return (
<section className="relative flex flex-col justify-center">
{/* header */}
<div className="flex justify-between gap-2 items-center mb-1">
<CardHeading> Payouts </CardHeading>
{uiQuery.data && (
<IntervalSelector
intervalType={intervalType}
setIntervalType={setIntervalType}
/>
)}
</div>
{!uiQuery.isError ? (
<RenderData
data={uiQuery.data}
intervalType={intervalType}
setIntervalType={setIntervalType}
/>
) : (
<NoDataAvailable />
)}
</section>
);
}
function RenderData(props: {
data?: UIQueryData;
intervalType: "day" | "week";
setIntervalType: (intervalType: "day" | "week") => void;
}) {
return (
<div>
<div className="flex items-center gap-3 mb-5">
<SkeletonContainer
loadedData={props.data?.totalPayoutsUSD}
skeletonData={20}
render={(value) => {
return (
<p className="text-4xl tracking-tighter font-semibold">
${value.toLocaleString()}
</p>
);
}}
/>
<SkeletonContainer
className="rounded-2xl"
loadedData={props.data?.percentChange}
skeletonData={1}
render={(percent) => {
return <ChangeBadge percent={percent} />;
}}
/>
</div>
<div className="relative flex justify-center w-full">
{props.data?.graphData ? (
<ResponsiveContainer width="100%" height={chartHeight}>
<BarChart data={props.data.graphData}>
<Tooltip
content={(x) => {
const payload = x.payload?.[0]?.payload as
| GraphData
| undefined;
return (
<div className="bg-popover px-4 py-2 rounded border border-border">
<p className="text-muted-foreground mb-1 text-sm">
{payload?.date}
</p>
<p className="text-medium text-base">
${payload?.value.toLocaleString()}
</p>
</div>
);
}}
cursor={{ fill: "hsl(var(--accent))", radius: 8 }}
/>
<Bar
dataKey="value"
stroke="none"
fillOpacity={1}
fill={"hsl(var(--link-foreground))"}
radius={8}
barSize={20}
/>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
className="text-xs font-sans"
stroke="hsl(var(--muted-foreground))"
dy={10}
/>
</BarChart>
</ResponsiveContainer>
) : (
<AreaChartLoadingState height={`${chartHeight}px`} />
)}
</div>
{props.data && (
<div className="absolute top-0 right-0">
<IntervalSelector
intervalType={props.intervalType}
setIntervalType={props.setIntervalType}
/>
</div>
)}
</div>
);
}
@@ -0,0 +1,223 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { format } from "date-fns";
import { useEffect, useId, useState } from "react";
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import { AreaChartLoadingState } from "../../../analytics/area-chart";
import { type PayVolumeData, usePayVolume } from "../hooks/usePayVolume";
import {
CardHeading,
IntervalSelector,
NoDataAvailable,
chartHeight,
} from "./common";
type GraphData = {
date: string;
value: number;
};
export function TotalPayVolume(props: {
clientId: string;
from: Date;
to: Date;
numberOfDays: number;
}) {
const [intervalType, setIntervalType] = useState<"day" | "week">(
props.numberOfDays > 30 ? "week" : "day",
);
// if prop changes, update intervalType
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
setIntervalType(props.numberOfDays > 30 ? "week" : "day");
}, [props.numberOfDays]);
const volumeQuery = usePayVolume({
clientId: props.clientId,
from: props.from,
intervalType,
to: props.to,
});
return (
<section className="relative flex flex-col justify-center">
{!volumeQuery.isError ? (
<RenderData
data={volumeQuery.data}
intervalType={intervalType}
setIntervalType={setIntervalType}
/>
) : (
<NoDataAvailable />
)}
</section>
);
}
function RenderData(props: {
data?: PayVolumeData;
intervalType: "day" | "week";
setIntervalType: (intervalType: "day" | "week") => void;
}) {
const uniqueId = useId();
const [successType, setSuccessType] = useState<"success" | "fail">("success");
const [type, setType] = useState<"all" | "crypto" | "fiat">("all");
const graphData: GraphData[] | undefined = props.data?.intervalResults.map(
(x) => {
const date = format(new Date(x.interval), "LLL dd");
switch (type) {
case "crypto": {
return {
date,
value:
x.buyWithCrypto[
successType === "success" ? "succeeded" : "failed"
].amountUSDCents / 100,
};
}
case "fiat": {
return {
date,
value:
x.buyWithFiat[successType === "success" ? "succeeded" : "failed"]
.amountUSDCents / 100,
};
}
case "all": {
return {
date,
value:
x.sum[successType === "success" ? "succeeded" : "failed"]
.amountUSDCents / 100,
};
}
default: {
throw new Error("Invalid tab");
}
}
},
);
if (graphData?.length === 0) {
return <NoDataAvailable />;
}
const chartColor =
successType === "success"
? "hsl(var(--success-foreground))"
: "hsl(var(--destructive-foreground))";
return (
<div className="flex flex-col flex-1">
<div className="flex flex-col lg:flex-row lg:justify-between gap-2 lg:items-center">
<CardHeading>Volume</CardHeading>
{graphData && (
<div className="flex gap-2">
<Select
value={type}
onValueChange={(value: "all" | "crypto" | "fiat") => {
setType(value);
}}
>
<SelectTrigger className="bg-transparent">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">Total</SelectItem>
<SelectItem value="crypto">Crypto</SelectItem>
<SelectItem value="fiat">Fiat</SelectItem>
</SelectContent>
</Select>
<Select
value={successType}
onValueChange={(value: "success" | "fail") => {
setSuccessType(value);
}}
>
<SelectTrigger className="bg-transparent">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="success">Successful</SelectItem>
<SelectItem value="fail">Failed</SelectItem>
</SelectContent>
</Select>
<IntervalSelector
intervalType={props.intervalType}
setIntervalType={props.setIntervalType}
/>
</div>
)}
</div>
<div className="h-10" />
<div className="flex justify-center w-full flex-1">
{graphData ? (
<ResponsiveContainer width="100%" height={chartHeight}>
<AreaChart data={graphData}>
<defs>
<linearGradient id={uniqueId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={chartColor} stopOpacity={0.4} />
<stop offset="95%" stopColor={chartColor} stopOpacity={0.0} />
</linearGradient>
</defs>
<Tooltip
content={(x) => {
const payload = x.payload?.[0]?.payload as
| GraphData
| undefined;
return (
<div className="bg-popover px-4 py-2 rounded border border-border">
<p className="text-muted-foreground mb-1 text-sm">
{payload?.date}
</p>
<p className="text-medium text-base">
${payload?.value.toLocaleString()}
</p>
</div>
);
}}
/>
<Area
type="monotone"
dataKey="value"
stroke={chartColor}
fillOpacity={1}
fill={`url(#${uniqueId})`}
strokeWidth={2}
strokeLinecap="round"
/>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
className="text-xs font-sans"
stroke="hsl(var(--muted-foreground))"
dy={10}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<AreaChartLoadingState height={`${chartHeight}px`} />
)}
</div>
</div>
);
}
@@ -0,0 +1,212 @@
import { cn } from "@/lib/utils";
import { Cell, Pie, PieChart } from "recharts";
import { SkeletonContainer } from "../../../../@/components/ui/skeleton";
import { usePayVolume } from "../hooks/usePayVolume";
import { NoDataAvailable, chartHeight } from "./common";
type VolData = {
name: string;
amount: number;
color: string;
};
type UIQueryData = {
totalAmount: number;
cryptoTotalUSD: number;
fiatTotalUSD: number;
};
function getUIData(volumeQuery: ReturnType<typeof usePayVolume>): {
data?: UIQueryData;
isError?: boolean;
isLoading?: boolean;
} {
if (volumeQuery.isLoading) {
return { isLoading: true };
}
if (volumeQuery.isError) {
return { isError: true };
}
if (volumeQuery.data.aggregate.sum.succeeded.amountUSDCents === 0) {
return { isError: true };
}
const cryptoTotalUSD = Math.ceil(
volumeQuery.data.aggregate.buyWithCrypto.succeeded.amountUSDCents / 100,
);
const fiatTotalUSD = Math.ceil(
volumeQuery.data.aggregate.buyWithFiat.succeeded.amountUSDCents / 100,
);
const totalAmount = cryptoTotalUSD + fiatTotalUSD;
const data: UIQueryData = {
totalAmount,
cryptoTotalUSD,
fiatTotalUSD,
};
return { data };
}
export function TotalVolumePieChart(props: {
clientId: string;
from: Date;
to: Date;
}) {
const volumeQuery = usePayVolume({
clientId: props.clientId,
from: props.from,
intervalType: "day",
to: props.to,
});
const uiData = getUIData(volumeQuery);
return (
<section className="w-full">
{!uiData.isError ? (
<RenderData data={uiData.data} />
) : (
<NoDataAvailable />
)}
</section>
);
}
function RenderData(props: { data?: UIQueryData }) {
const queryData = props.data;
const skeletonData: VolData[] = [
{
name: "Crypto",
amount: 50,
color: "hsl(var(--muted))",
},
{
name: "Fiat",
amount: 50,
color: "hsl(var(--muted))",
},
];
const volumeData: VolData[] = queryData
? [
{
name: "Crypto",
amount: queryData.cryptoTotalUSD,
color: "#6366f1",
},
{
name: "Fiat",
amount: queryData.fiatTotalUSD,
color: "#d946ef",
},
]
: skeletonData;
return (
<div className="flex flex-col lg:flex-row gap-6 justify-center">
{/* Left */}
<div className="relative flex justify-center">
<PieChart width={250} height={chartHeight}>
<Pie
style={{
outline: "none",
}}
activeIndex={0}
data={volumeData}
dataKey="amount"
cx="50%"
cy="50%"
innerRadius="80%"
outerRadius="100%"
stroke="none"
cornerRadius={100}
paddingAngle={5}
>
{volumeData.map((entry, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: ok
<Cell key={index} fill={entry.color} />
))}
</Pie>
</PieChart>
<div className="absolute inset-0 flex items-center justify-center p-10">
<div className="flex flex-col gap-1 items-center">
<p className="text-sm font-medium"> Total Volume</p>
<SkeletonContainer
loadedData={queryData?.totalAmount}
skeletonData={100}
render={(v) => {
const totalAmount = `$${v.toLocaleString()}`;
return (
<p
className={cn(
"text-3xl font-semibold tracking-tighter",
totalAmount.length > 6 ? "text-3xl" : "text-4xl",
)}
>
{totalAmount}
</p>
);
}}
/>
</div>
</div>
</div>
{/* Right */}
<div className="lg:flex items-center border-t border-border pt-5 lg:pt-0 lg:border-none lg:pr-10">
<div className="flex lg:flex-col gap-10 lg:gap-4 justify-center">
{volumeData.map((v) => (
<VolumeLegend
key={v.name}
color={v.color}
label={v.name}
amount={queryData ? v.amount : undefined}
/>
))}
</div>
</div>
</div>
);
}
function VolumeLegend(props: {
color: string;
label: string;
amount?: number;
}) {
return (
<div className="flex items-start gap-2">
<div
className="size-5 rounded mt-1"
style={{
background: props.color,
}}
/>
<div>
<p className="text-secondary-foreground font-medium mb-1">
{props.label}
</p>
<SkeletonContainer
loadedData={props.amount}
skeletonData={50}
render={(amount) => {
return (
<p className="text-2xl text-foreground font-semibold tracking-tight">
${amount.toLocaleString()}
</p>
);
}}
/>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
import { Badge } from "@/components/ui/badge";
import { ArrowDownIcon, ArrowUpIcon, OctagonXIcon } from "lucide-react";
import { ToolTipLabel } from "../../../../@/components/ui/tooltip";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function NoDataAvailable() {
return (
<div className="min-h-[250px] flex items-center justify-center flex-1">
<div className="flex items-center gap-2">
<OctagonXIcon className="size-5 text-destructive-foreground" />
<p className="text-muted-foreground">No data available</p>
</div>
</div>
);
}
export function CardHeading(props: { children: React.ReactNode }) {
return <h3 className="text-base font-medium">{props.children}</h3>;
}
export function ChangeBadge(props: { percent: number }) {
const percentValue = `${props.percent.toFixed(0)}%`;
let label = "No change compared to prior range";
if (props.percent !== 0) {
label = `
${props.percent >= 0 ? "Increase" : "Decrease"} of ${percentValue} compared to prior range
`;
}
return (
<ToolTipLabel label={label}>
<div>
<Badge
variant={props.percent >= 0 ? "success" : "destructive"}
className="text-sm gap-1 px-2 py-1.5"
>
{props.percent >= 0 ? (
<ArrowUpIcon className="size-4 " />
) : (
<ArrowDownIcon className="size-4" />
)}
{percentValue}
</Badge>
</div>
</ToolTipLabel>
);
}
export function TableData({ children }: { children: React.ReactNode }) {
return <td className="px-3 py-2 text-sm">{children}</td>;
}
export function TableHeadingRow({ children }: { children: React.ReactNode }) {
return (
<tr className="sticky top-0 bg-background z-10">
{children}
<div className="border-b border-border absolute inset-0 z-10" />
</tr>
);
}
export function TableHeading(props: { children: React.ReactNode }) {
return (
<th className="bg-secondary border-b border-border text-left px-3 py-3 text-sm font-medium text-muted-foreground min-w-[150px]">
{props.children}
</th>
);
}
export function IntervalSelector(props: {
intervalType: "day" | "week";
setIntervalType: (intervalType: "day" | "week") => void;
}) {
return (
<Select
value={props.intervalType}
onValueChange={(value: "day" | "week") => {
props.setIntervalType(value);
}}
>
<SelectTrigger className="bg-transparent w-auto">
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="week"> Weekly </SelectItem>
<SelectItem value="day"> Daily</SelectItem>
</SelectContent>
</Select>
);
}
export const chartHeight = 220;
@@ -0,0 +1,78 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useLoggedInUser } from "../../../../@3rdweb-sdk/react/hooks/useLoggedInUser";
import { THIRDWEB_PAY_DOMAIN } from "../../../../constants/urls";
export type PayTopCustomersData = {
count: number;
customers: Array<{
walletAddress: string;
totalSpendUSDCents: number;
}>;
};
type Response = {
result: {
data: PayTopCustomersData;
};
};
export function usePayCustomers(options: {
clientId: string;
from: Date;
to: Date;
pageSize: number;
type: "top-customers" | "new-customers";
}) {
const { user, isLoggedIn } = useLoggedInUser();
return useInfiniteQuery({
queryKey: ["usePayCustomers", user?.address, options],
queryFn: async ({ pageParam = 0 }) => {
const endpoint = new URL(
options.type === "new-customers"
? `https://${THIRDWEB_PAY_DOMAIN}/stats/new-customers/v1`
: `https://${THIRDWEB_PAY_DOMAIN}/stats/customers/v1`,
);
const start = options.pageSize * pageParam;
endpoint.searchParams.append("skip", `${start}`);
endpoint.searchParams.append("take", `${options.pageSize}`);
endpoint.searchParams.append("clientId", options.clientId);
endpoint.searchParams.append("fromDate", `${options.from.getTime()}`);
endpoint.searchParams.append("toDate", `${options.to.getTime()}`);
const res = await fetch(endpoint.toString(), {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("Failed to fetch pay volume");
}
const resJSON = (await res.json()) as Response;
const pageData = resJSON.result.data;
const itemsRequested = options.pageSize * (pageParam + 1);
const totalItems = pageData.count;
let nextPageIndex: number | null = null;
if (itemsRequested < totalItems) {
nextPageIndex = pageParam + 1;
}
return {
pageData: resJSON.result.data,
nextPageIndex,
};
},
enabled: isLoggedIn,
getNextPageParam: (lastPage) => {
return lastPage.nextPageIndex;
},
});
}
@@ -0,0 +1,64 @@
import { useQuery } from "@tanstack/react-query";
import { useLoggedInUser } from "../../../../@3rdweb-sdk/react/hooks/useLoggedInUser";
import { THIRDWEB_PAY_DOMAIN } from "../../../../constants/urls";
export type PayNewCustomersData = {
intervalType: "day" | "week";
intervalResults: Array<{
/**
* Date formatted in ISO 8601 format
*/
interval: string;
distinctCustomers: number;
}>;
aggregate: {
// totals in the [fromDate, toDate] range
distinctCustomers: number;
bpsIncreaseFromPriorRange: number;
};
};
type Response = {
result: {
data: PayNewCustomersData;
};
};
export function usePayNewCustomers(options: {
clientId: string;
from: Date;
to: Date;
intervalType: "day" | "week";
}) {
const { user, isLoggedIn } = useLoggedInUser();
return useQuery(
["usePayNewCustomers", user?.address, options],
async () => {
const endpoint = new URL(
`https://${THIRDWEB_PAY_DOMAIN}/stats/aggregate/customers/v1`,
);
endpoint.searchParams.append("intervalType", options.intervalType);
endpoint.searchParams.append("clientId", options.clientId);
endpoint.searchParams.append("fromDate", `${options.from.getTime()}`);
endpoint.searchParams.append("toDate", `${options.to.getTime()}`);
const res = await fetch(endpoint.toString(), {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("Failed to fetch new customers");
}
const resJSON = (await res.json()) as Response;
return resJSON.result.data;
},
{ enabled: isLoggedIn },
);
}
@@ -0,0 +1,99 @@
import { useQuery } from "@tanstack/react-query";
import { useLoggedInUser } from "../../../../@3rdweb-sdk/react/hooks/useLoggedInUser";
import { THIRDWEB_PAY_DOMAIN } from "../../../../constants/urls";
export type PayPurchasesData = {
count: number;
purchases: Array<
{
createdAt: string;
updatedAt: string;
status: "COMPLETED" | "FAILED" | "PENDING";
fromAddress: string;
estimatedFeesUSDCents: number;
fromAmountUSDCents: number;
toAmountUSDCents: number;
toAmountWei: string;
toAmount: string;
purchaseId: string;
toToken: {
chainId: number;
decimals: number;
symbol: string;
name: string;
tokenAddress: string;
};
} & (
| {
purchaseType: "ONRAMP";
fromCurrencyDecimals: number;
fromCurrencySymbol: string;
fromAmountUnits: string;
}
| {
purchaseType: "SWAP";
fromAmountWei: string;
fromAmount: string;
fromToken: {
chainId: number;
decimals: number;
symbol: string;
name: string;
tokenAddress: string;
};
}
)
>;
};
type Response = {
result: {
data: PayPurchasesData;
};
};
type PayPurcaseOptions = {
clientId: string;
from: Date;
to: Date;
start: number;
count: number;
};
export function usePayPurchases(options: PayPurcaseOptions) {
const { user, isLoggedIn } = useLoggedInUser();
return useQuery({
queryKey: ["usePayPurchases", user?.address, options],
queryFn: async () => getPayPurchases(options),
enabled: isLoggedIn,
keepPreviousData: true,
});
}
export async function getPayPurchases(options: PayPurcaseOptions) {
const endpoint = new URL(`https://${THIRDWEB_PAY_DOMAIN}/stats/purchases/v1`);
endpoint.searchParams.append("skip", `${options.start}`);
endpoint.searchParams.append("take", `${options.count}`);
endpoint.searchParams.append("clientId", options.clientId);
endpoint.searchParams.append("fromDate", `${options.from.getTime()}`);
endpoint.searchParams.append("toDate", `${options.to.getTime()}`);
const res = await fetch(endpoint.toString(), {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("Failed to fetch pay volume");
}
const resJSON = (await res.json()) as Response;
return resJSON.result.data;
}
@@ -0,0 +1,95 @@
import { useQuery } from "@tanstack/react-query";
import { useLoggedInUser } from "../../../../@3rdweb-sdk/react/hooks/useLoggedInUser";
import { THIRDWEB_PAY_DOMAIN } from "../../../../constants/urls";
type AggregatedData = {
succeeded: {
amountUSDCents: number;
count: number;
bpsIncreaseFromPriorRange: number;
};
failed: {
amountUSDCents: number;
count: number;
};
};
type IntervalResultNode = {
failed: {
amountUSDCents: number;
count: number;
};
succeeded: {
amountUSDCents: number;
count: number;
};
};
export type PayVolumeData = {
intervalType: "day" | "week";
intervalResults: Array<{
interval: string;
buyWithCrypto: IntervalResultNode;
buyWithFiat: IntervalResultNode;
sum: IntervalResultNode;
payouts: {
amountUSDCents: number;
count: number;
};
}>;
aggregate: {
buyWithCrypto: AggregatedData;
buyWithFiat: AggregatedData;
sum: AggregatedData;
payouts: {
amountUSDCents: number;
count: number;
bpsIncreaseFromPriorRange: number;
};
};
};
type Response = {
result: {
data: PayVolumeData;
};
};
export function usePayVolume(options: {
clientId: string;
from: Date;
to: Date;
intervalType: "day" | "week";
}) {
const { user, isLoggedIn } = useLoggedInUser();
return useQuery(
["usePayVolume", user?.address, options],
async () => {
const endpoint = new URL(
`https://${THIRDWEB_PAY_DOMAIN}/stats/aggregate/volume/v1`,
);
endpoint.searchParams.append("intervalType", options.intervalType);
endpoint.searchParams.append("clientId", options.clientId);
endpoint.searchParams.append("fromDate", `${options.from.getTime()}`);
endpoint.searchParams.append("toDate", `${options.to.getTime()}`);
const res = await fetch(endpoint.toString(), {
method: "GET",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
});
if (!res.ok) {
throw new Error("Failed to fetch pay volume");
}
const resJSON = (await res.json()) as Response;
return resJSON.result.data;
},
{ enabled: isLoggedIn, retry: false },
);
}
@@ -73,7 +73,7 @@ export const CustomChainRenderer = ({
)}
</div>
{switching && (
<div className="flex text-primary-foreground text-xs font-medium items-center gap-1">
<div className="flex text-link-foreground text-xs font-medium items-center gap-1">
Confirm in your wallet
<Spinner className="size-3" />
</div>
+13
View File
@@ -10,3 +10,16 @@ export const THIRDWEB_EWS_API_HOST =
export const THIRDWEB_PAYMENTS_API_HOST =
process.env.NEXT_PUBLIC_THIRDWEB_PAYMENTS_API_HOST ||
"https://payments.thirdweb.com";
export const THIRDWEB_PAY_DOMAIN =
process.env.NEXT_PUBLIC_PAY_URL || "pay.thirdweb-dev.com";
export const THIRDWEB_INAPP_WALLET_DOMAIN =
process.env.NEXT_PUBLIC_IN_APP_WALLET_URL ||
"embedded-wallet.thirdweb-dev.com";
export const THIRDWEB_RPC_DOMAIN =
process.env.NEXT_PUBLIC_RPC_URL || "rpc.thirdweb-dev.com";
export const THIRDWEB_STORAGE_DOMAIN =
process.env.NEXT_PUBLIC_STORAGE_URL || "storage.thirdweb-dev.com";
@@ -118,7 +118,7 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
transaction to this contract.
</Text>
</Stack>
<ChartContainer w="full" ratio={4.5 / 1}>
<ChartContainer className="w-full" ratio={4.5 / 1}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={evmContractInfo.chain.chainId}
@@ -142,7 +142,7 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
contract.
</Text>
</Stack>
<ChartContainer w="full" ratio={4.5 / 1}>
<ChartContainer className="w-full" ratio={4.5 / 1}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={evmContractInfo.chain.chainId}
@@ -167,7 +167,7 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
contract.
</Text>
</Stack>
<ChartContainer w="full" ratio={4.5 / 1}>
<ChartContainer className="w-full" ratio={4.5 / 1}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={evmContractInfo.chain.chainId}
@@ -191,7 +191,7 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
transactions.
</Text>
</Stack>
<ChartContainer w="full" ratio={4.5 / 1}>
<ChartContainer className="w-full" ratio={4.5 / 1}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={evmContractInfo.chain.chainId}
@@ -212,7 +212,7 @@ export const ContractAnalyticsPage: React.FC<ContractAnalyticsPageProps> = ({
</Heading>
<Text>The breakdown of events emitted by this contract.</Text>
</Stack>
<ChartContainer w="full" ratio={4.5 / 1}>
<ChartContainer className="w-full" ratio={4.5 / 1}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={evmContractInfo.chain.chainId}
@@ -56,7 +56,7 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
</TrackedLink>
</Flex>
<SimpleGrid gap={{ base: 3, md: 6 }} columns={{ base: 2, md: 3 }}>
<SimpleGrid gap={{ base: 3, md: 6 }} columns={{ base: 1, md: 3 }}>
<GridItem>
<Flex
flexDir="column"
@@ -83,7 +83,7 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
<Icon as={FiInfo} boxSize={4} color="gray.700" />
</Tooltip>
</Flex>
<ChartContainer w="full" ratio={1.7}>
<ChartContainer className="w-full" ratio={1.7}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={chainId}
@@ -125,7 +125,7 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
<Icon as={FiInfo} boxSize={4} color="gray.700" />
</Tooltip>
</Flex>
<ChartContainer w="full" ratio={1.7}>
<ChartContainer className="w-full" ratio={1.7}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={chainId}
@@ -167,7 +167,7 @@ export const AnalyticsOverview: React.FC<AnalyticsOverviewProps> = ({
<Icon as={FiInfo} boxSize={4} color="gray.700" />
</Tooltip>
</Flex>
<ChartContainer w="full" ratio={1.7}>
<ChartContainer className="w-full" ratio={1.7}>
<AnalyticsChart
contractAddress={contractAddress}
chainId={chainId}
+1 -2
View File
@@ -84,8 +84,8 @@ export const SidebarNav: React.FC<SidebarNavProps> = ({
<Box
position={{ base: "absolute", md: "relative" }}
maxH={{ base: openState.isOpen ? "100vh" : "0px", md: "100%" }}
bg="backgroundHighlight"
transition="max-height 0.2s ease-in-out"
bg="backgroundBody"
overflow={{ base: "hidden", md: "visible" }}
w="full"
mb={{ base: 3, md: 0 }}
@@ -149,7 +149,6 @@ export const SidebarNav: React.FC<SidebarNavProps> = ({
right={0}
left={0}
backdropFilter={openState.isOpen ? "blur(5px)" : undefined}
transition="backdrop-filter 0.2s ease-in-out"
/>
</>
</SideBarTunnel>
+10 -6
View File
@@ -1,5 +1,11 @@
import { setThirdwebDomains } from "thirdweb/utils";
import { isBrowser } from "utils/isBrowser";
import {
THIRDWEB_INAPP_WALLET_DOMAIN,
THIRDWEB_PAY_DOMAIN,
THIRDWEB_RPC_DOMAIN,
THIRDWEB_STORAGE_DOMAIN,
} from "../constants/urls";
function getVercelEnv() {
const onVercel = process.env.vercel || process.env.NEXT_PUBLIC_VERCEL_ENV;
@@ -53,12 +59,10 @@ function setOverrides() {
*/
setThirdwebDomains({
rpc: process.env.NEXT_PUBLIC_RPC_URL || "rpc.thirdweb-dev.com",
inAppWallet:
process.env.NEXT_PUBLIC_IN_APP_WALLET_URL ||
"embedded-wallet.thirdweb-dev.com",
pay: process.env.NEXT_PUBLIC_PAY_URL || "pay.thirdweb-dev.com",
storage: process.env.NEXT_PUBLIC_STORAGE_URL || "storage.thirdweb-dev.com",
rpc: THIRDWEB_RPC_DOMAIN,
inAppWallet: THIRDWEB_INAPP_WALLET_DOMAIN,
pay: THIRDWEB_PAY_DOMAIN,
storage: THIRDWEB_STORAGE_DOMAIN,
});
}
+8 -6
View File
@@ -1,9 +1,12 @@
import { ChakraProvider, useColorMode } from "@chakra-ui/react";
import { Global, css } from "@emotion/react";
import type { DehydratedState } from "@tanstack/react-query";
import { AnnouncementBanner } from "components/notices/AnnouncementBanner";
import { ProgressBar } from "components/shared/ProgressBar";
import { useBuildId } from "hooks/useBuildId";
import PlausibleProvider from "next-plausible";
import { DefaultSeo } from "next-seo";
import { useTheme } from "next-themes";
import type { AppProps } from "next/app";
import {
IBM_Plex_Mono as ibmPlexMonoConstructor,
@@ -16,13 +19,11 @@ import posthogOpenSource from "posthog-js-opensource";
import { memo, useEffect, useMemo, useRef } from "react";
import { generateBreakpointTypographyCssVars } from "tw-components/utils/typography";
import type { ThirdwebNextPage } from "utils/types";
import chakraTheme from "../theme";
import "../css/swagger-ui.css";
import { AnnouncementBanner } from "components/notices/AnnouncementBanner";
import { useBuildId } from "hooks/useBuildId";
import "@/styles/globals.css";
import { useTheme } from "next-themes";
import { ThemeProvider } from "../@/components/theme-provider";
import "../css/swagger-ui.css";
import { Toaster } from "@/components/ui/toaster";
import chakraTheme from "../theme";
import "@/styles/globals.css";
const inter = interConstructor({
subsets: ["latin"],
@@ -275,6 +276,7 @@ const ConsoleApp = memo(function ConsoleApp({
{isFallback && Component.fallback
? Component.fallback
: getLayout(<Component {...pageProps} />, pageProps)}
<Toaster />
</TailwindTheme>
</ChakraProvider>
</PlausibleProvider>
@@ -295,7 +295,7 @@ const DashboardConnectAnalytics: ThirdwebNextPage = () => {
app each day.
</Text>
</Stack>
<ChartContainer w="full" ratio={21 / 9}>
<ChartContainer className="w-full" ratio={21 / 9}>
<AutoBarChart
data={barChartData}
showXAxis
@@ -316,7 +316,7 @@ const DashboardConnectAnalytics: ThirdwebNextPage = () => {
each day.
</Text>
</Stack>
<ChartContainer w="full" ratio={21 / 9}>
<ChartContainer className="w-full" ratio={21 / 9}>
<AutoBarChart
data={walletBarChartData}
showXAxis
@@ -337,7 +337,10 @@ const DashboardConnectAnalytics: ThirdwebNextPage = () => {
Distribution of wallet types used to connect to your app.
</Text>
</Stack>
<ChartContainer w="full" ratio={isMobile ? 1.5 : 21 / 9}>
<ChartContainer
className="w-full"
ratio={isMobile ? 1.5 : 21 / 9}
>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
@@ -7,7 +7,6 @@ import {
type BoxProps,
Flex,
FormControl,
HStack,
Tab,
TabList,
TabPanel,
@@ -28,10 +27,13 @@ import { NoApiKeys } from "components/settings/ApiKeys/NoApiKeys";
import { ConnectWalletPrompt } from "components/settings/ConnectWalletPrompt";
import { ConnectSidebar } from "core-ui/sidebar/connect";
import { useRouter } from "next/router";
import { PageId } from "page-id";
import { useEffect, useMemo, useState } from "react";
import { Heading, Text, TrackedLink } from "tw-components";
import type { ThirdwebNextPage } from "utils/types";
import { Spinner } from "../../../@/components/ui/Spinner/Spinner";
import { TabButtons } from "../../../@/components/ui/tabs";
import { PayAnalytics } from "../../../components/pay/PayAnalytics/PayAnalytics";
import { PageId } from "../../../page-id";
import { TrackedLink } from "../../../tw-components";
import type { ThirdwebNextPage } from "../../../utils/types";
const TRACKING_CATEGORY = "pay";
@@ -142,6 +144,7 @@ function usePayConfig() {
setSelectedKey,
apiKeysData,
hasApiKeys: !!keysQuery.data?.length,
isFetchingKeys: keysQuery.isFetching && !keysQuery.isRefetching,
};
}
@@ -169,60 +172,60 @@ const DashboardConnectPay: ThirdwebNextPage = () => {
onChange: (value: "pay" | "checkouts") => setTabOption(value),
});
const { isLoggedIn } = useLoggedInUser();
const { isLoggedIn, isLoading } = useLoggedInUser();
const {
hasApiKeys,
hasPayApiKeys,
selectedKey,
setSelectedKey,
apiKeysData,
isFetchingKeys,
} = usePayConfig();
// Pay setting api key configuration
if (isLoading || isFetchingKeys) {
return (
<div className="min-h-[calc(100vh-300px)] lg:min-h-[calc(100vh-250px)] flex items-center justify-center">
<Spinner className="size-14" />
</div>
);
}
if (!isLoggedIn) {
return <ConnectWalletPrompt description="manage your Pay configuration" />;
}
return (
<Flex flexDir="column" gap={8}>
<Flex
flexDir={{ base: "column", lg: "row" }}
alignItems={{ base: "start", lg: "end" }}
w="full"
gap={4}
justifyContent={"space-between"}
>
<Flex flexDir={"column"} gap={2}>
<Heading size="title.lg" as="h1">
Pay Settings
</Heading>
<Text maxW="xl">
Configure developer settings for all Pay features, including{" "}
<div className="flex flex-col lg:flex-row gap-6 justify-between items-start">
<div className="max-w-[800px]">
<h1 className="text-5xl tracking-tight font-bold mb-5">Pay</h1>
<p className="text-secondary-foreground leading-7">
Pay allows your users to purchase cryptocurrencies and execute
transactions with their credit card or debit card, or with any token
via cross-chain routing.{" "}
<TrackedLink
isExternal
category={TRACKING_CATEGORY}
href="https://portal.thirdweb.com/connect/pay/buy-with-crypto"
label="buy-with-crypto-docs"
color="primary.500"
href="https://portal.thirdweb.com/connect/pay/overview"
label="pay-docs"
className="!text-link-foreground"
>
Buy With Crypto
Learn more
</TrackedLink>
.
</Text>
</Flex>
</p>
</div>
{hasPayApiKeys && tabOption === "pay" && (
<HStack gap={3}>
{selectedKey && (
<ApiKeysMenu
apiKeys={apiKeysData}
selectedKey={selectedKey}
onSelect={setSelectedKey}
/>
)}
</HStack>
)}
</Flex>
<div className="w-full lg:max-w-[300px]">
{hasPayApiKeys && tabOption === "pay" && selectedKey && (
<ApiKeysMenu
apiKeys={apiKeysData}
selectedKey={selectedKey}
onSelect={setSelectedKey}
/>
)}
</div>
</div>
{radioOptions.length > 1 && (
<FormControl>
@@ -264,26 +267,68 @@ const DashboardConnectPay: ThirdwebNextPage = () => {
</TabPanels>
</Tabs>
) : (
<>
{!hasPayApiKeys && (
<NoApiKeys
service="Pay in Connect"
buttonTextOverride={hasApiKeys ? "Enable Pay" : undefined}
copyOverride={
hasApiKeys
? "You'll need to enable pay as a service in an API Key to use Pay."
: undefined
}
/>
)}
{hasPayApiKeys && selectedKey && <PayConfig apiKey={selectedKey} />}
</>
<PayUI
hasPayApiKeys={hasPayApiKeys}
hasApiKeys={hasApiKeys}
selectedKey={selectedKey}
/>
)}
</Flex>
);
};
function PayUI(props: {
hasPayApiKeys: boolean;
hasApiKeys: boolean;
selectedKey: ApiKey | undefined;
}) {
const { hasPayApiKeys, hasApiKeys, selectedKey } = props;
const [activeTab, setActiveTab] = useState<"settings" | "analytics">(
"analytics",
);
return (
<div>
{!hasPayApiKeys && (
<NoApiKeys
service="Pay in Connect"
buttonTextOverride={hasApiKeys ? "Enable Pay" : undefined}
copyOverride={
hasApiKeys
? "You'll need to enable pay as a service in an API Key to use Pay."
: undefined
}
/>
)}
{hasPayApiKeys && selectedKey && (
<>
<TabButtons
tabs={[
{
name: "Analytics",
isActive: activeTab === "analytics",
onClick: () => setActiveTab("analytics"),
isEnabled: true,
},
{
name: "Settings",
isActive: activeTab === "settings",
onClick: () => setActiveTab("settings"),
isEnabled: true,
},
]}
/>
<div className="h-5" />
{activeTab === "settings" && <PayConfig apiKey={selectedKey} />}
{activeTab === "analytics" && <PayAnalytics apiKey={selectedKey} />}
</>
)}
</div>
);
}
DashboardConnectPay.getLayout = (page, props) => (
<AppLayout {...props} hasSidebar={true}>
<ConnectSidebar activePage="pay-settings" />
+15 -7
View File
@@ -383,17 +383,25 @@ const chakraTheme: Theme = extendTheme({
// borderColor: { default: "accent.200", _dark: "accent.300" },
bgBlack: { default: "black", _dark: "white" },
bgWhite: { default: "#fff", _dark: "black" },
backgroundBody: { default: "backgroundLight", _dark: "backgroundDark" },
backgroundHighlight: { default: "white", _dark: "#131417" },
alertBg: {
default: "hsl(220 18% 92% / 1)",
_dark: "hsl(225 10% 12.5% / 1)",
backgroundBody: {
default: "hsl(var(--background))",
_dark: "hsl(var(--background))",
},
backgroundHighlight: {
default: "hsl(var(--secondary))",
_dark: "hsl(var(--secondary))",
},
secondaryCardHighlight: {
default: "hsl(var(--secondary))",
_dark: "hsl(var(--secondary))",
},
backgroundCardHighlight: { default: "white", _dark: "#131417" },
wordmark: { default: "#262A36", _dark: "whiteAlpha.900" },
heading: { default: "#262A36", _dark: "#ECECEC" },
paragraph: { default: "rgba(39, 46, 54, 0.9)", _dark: "#b2b2b2" },
faded: { default: "rgba(39, 46, 54, 0.6)", _dark: "#646D7A" },
faded: {
default: "hsl(var(--muted-foreground))",
_dark: "hsl(var(--muted-foreground))",
},
headingLight: { default: "#F2FBFF", _dark: "#262A36" },
paragraphLight: {
default: "rgba(242, 251, 255, 0.8)",
+6
View File
@@ -33,6 +33,7 @@ module.exports = {
foreground: "hsl(var(--destructive-foreground))",
},
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
muted: {
@@ -56,8 +57,12 @@ module.exports = {
foreground: "hsl(var(--inverted-foreground))",
},
warning: {
DEFAULT: "hsl(var(--warning))",
foreground: "hsl(var(--warning-foreground))",
},
link: {
foreground: "hsl(var(--link-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
@@ -77,6 +82,7 @@ module.exports = {
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
skeleton: "skeleton 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
},
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
+1 -1
View File
@@ -106,5 +106,5 @@
"ws@>=6.0.0 <6.2.3": "6.2.3"
}
},
"packageManager": "pnpm@8.14.1"
"packageManager": "pnpm@9.4.0"
}
+5433 -4311
View File
File diff suppressed because it is too large Load Diff