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:
@@ -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:
|
||||
|
||||
Vendored
+7
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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" />
|
||||
|
||||
+7
-86
@@ -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))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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
@@ -106,5 +106,5 @@
|
||||
"ws@>=6.0.0 <6.2.3": "6.2.3"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@8.14.1"
|
||||
"packageManager": "pnpm@9.4.0"
|
||||
}
|
||||
|
||||
Generated
+5433
-4311
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user