feat(v2): add website detail

This commit is contained in:
moonrailgun 2024-03-23 19:38:39 +08:00
parent f9a51e4c79
commit 958b1c0932
14 changed files with 642 additions and 167 deletions

View File

@ -92,7 +92,10 @@ export const App: React.FC = React.memo(() => {
> >
<TokenLoginContainer> <TokenLoginContainer>
{isDev ? ( {isDev ? (
<RouterProvider router={router} context={{ userInfo }} /> // Compatible with old routes
<BrowserRouter>
<RouterProvider router={router} context={{ userInfo }} />
</BrowserRouter>
) : ( ) : (
<BrowserRouter> <BrowserRouter>
<AppRoutes /> <AppRoutes />

View File

@ -0,0 +1,16 @@
import React from 'react';
interface CommonHeaderProps {
title: string;
actions?: React.ReactNode;
}
export const CommonHeader: React.FC<CommonHeaderProps> = React.memo((props) => {
return (
<>
<h1 className="text-xl font-bold">{props.title}</h1>
{props.actions && <div className="ml-auto">{props.actions}</div>}
</>
);
});
CommonHeader.displayName = 'CommonHeader';

View File

@ -1,22 +0,0 @@
import React from 'react';
import { Separator } from './ui/separator';
interface CommonSidebarProps extends React.PropsWithChildren {
header: React.ReactNode;
}
export const CommonSidebar: React.FC<CommonSidebarProps> = React.memo(
(props) => {
return (
<div className="h-full flex flex-col">
<div className="flex items-center px-4 py-2 h-[52px]">
{props.header}
</div>
<Separator />
<div className="flex-1 overflow-hidden">{props.children}</div>
</div>
);
}
);
CommonSidebar.displayName = 'CommonSidebar';

View File

@ -0,0 +1,22 @@
import React from 'react';
import { Separator } from './ui/separator';
interface CommonWrapperProps extends React.PropsWithChildren {
header?: React.ReactNode;
}
export const CommonWrapper: React.FC<CommonWrapperProps> = React.memo(
(props) => {
return (
<div className="flex h-full flex-col">
<div className="flex h-[52px] items-center px-4 py-2">
{props.header}
</div>
<Separator />
<div className="flex-1 overflow-hidden">{props.children}</div>
</div>
);
}
);
CommonWrapper.displayName = 'CommonWrapper';

View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/utils/style"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,138 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/utils/style"
const Sheet = SheetPrimitive.Root
const SheetTrigger = SheetPrimitive.Trigger
const SheetClose = SheetPrimitive.Close
const SheetPortal = SheetPrimitive.Portal
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
)
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
))
SheetContent.displayName = SheetPrimitive.Content.displayName
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
SheetHeader.displayName = "SheetHeader"
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
SheetFooter.displayName = "SheetFooter"
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
))
SheetTitle.displayName = SheetPrimitive.Title.displayName
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
SheetDescription.displayName = SheetPrimitive.Description.displayName
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -1,121 +0,0 @@
import React, { useState } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { hostnameRegex } from '@tianji/shared';
import { useTranslation } from '@i18next-toolkit/react';
import { LuPlus } from 'react-icons/lu';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogOverlay,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { useEvent } from '@/hooks/useEvent';
import { Input } from '../ui/input';
import { preventDefault } from '@/utils/dom';
import { trpc } from '@/api/trpc';
import { useCurrentWorkspaceId } from '@/store/user';
const addFormSchema = z.object({
name: z.string(),
domain: z.union([z.string().ip(), z.string().regex(hostnameRegex)]),
});
export const AddWebsiteBtn: React.FC = React.memo(() => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const workspaceId = useCurrentWorkspaceId();
const addWebsiteMutation = trpc.website.add.useMutation();
const utils = trpc.useUtils();
const form = useForm<z.infer<typeof addFormSchema>>({
resolver: zodResolver(addFormSchema),
defaultValues: {
name: '',
domain: '',
},
});
const onSubmit = useEvent(async (values: z.infer<typeof addFormSchema>) => {
await addWebsiteMutation.mutateAsync({
workspaceId,
name: values.name,
domain: values.domain,
});
utils.website.all.refetch();
form.reset();
setOpen(false);
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild={false}>
<Button variant="outline" Icon={LuPlus}>
{t('Add')}
</Button>
</DialogTrigger>
<DialogContent onPointerDownOutside={preventDefault}>
<DialogHeader>
<DialogTitle>{t('Add Website')}</DialogTitle>
<DialogDescription>{t('Add new website')}</DialogDescription>
</DialogHeader>
<div>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('Website Name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('Website Name to Display')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>{t('Domain')}</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={addWebsiteMutation.isLoading}>
{t('Submit')}
</Button>
</form>
</Form>
</div>
</DialogContent>
</Dialog>
);
});
AddWebsiteBtn.displayName = 'AddWebsiteBtn';

View File

@ -0,0 +1,53 @@
import React from 'react';
import { Button } from '../ui/button';
import { LuCode2 } from 'react-icons/lu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '../ui/dialog';
import { useTranslation } from '@i18next-toolkit/react';
import { Typography } from 'antd';
interface WebsiteCodeBtnProps {
websiteId: string;
}
export const WebsiteCodeBtn: React.FC<WebsiteCodeBtnProps> = React.memo(
(props) => {
const { t } = useTranslation();
const trackScript = `<script async defer src="${location.origin}/tracker.js" data-website-id="${props.websiteId}"></script>`;
return (
<Dialog>
<DialogTrigger>
<Button variant="outline" Icon={LuCode2}>
{t('Code')}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('Tracking code')}</DialogTitle>
<DialogDescription>
{t('Add this code into your website head script')}
</DialogDescription>
</DialogHeader>
<Typography.Paragraph
copyable={{
format: 'text/plain',
text: trackScript,
}}
className="bg-muted flex h-[96px] overflow-auto rounded border border-black border-opacity-10 bg-opacity-5 p-2"
>
<span>{trackScript}</span>
</Typography.Paragraph>
</DialogContent>
</Dialog>
);
}
);
WebsiteCodeBtn.displayName = 'WebsiteCodeBtn';

View File

@ -0,0 +1,39 @@
import { useTranslation } from '@i18next-toolkit/react';
import React from 'react';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '../ui/sheet';
import { Button } from '../ui/button';
import { WebsiteVisitorMap } from './WebsiteVisitorMap';
import { LuMap } from 'react-icons/lu';
interface WebsiteVisitorMapBtnProps {
websiteId: string;
}
export const WebsiteVisitorMapBtn: React.FC<WebsiteVisitorMapBtnProps> =
React.memo((props) => {
const { t } = useTranslation();
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" Icon={LuMap}>
{t('Vistor Map')}
</Button>
</SheetTrigger>
<SheetContent side="top">
<SheetHeader>
<SheetTitle>{t('Vistor Map')}</SheetTitle>
</SheetHeader>
<WebsiteVisitorMap websiteId={props.websiteId} />
</SheetContent>
</Sheet>
);
});
WebsiteVisitorMapBtn.displayName = 'WebsiteVisitorMapBtn';

View File

@ -120,12 +120,12 @@ export const LayoutV2: React.FC<{
<UserConfig isCollapsed={isCollapsed} /> <UserConfig isCollapsed={isCollapsed} />
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle /> <ResizableHandle withHandle />
<ResizablePanel defaultSize={layout[1]} minSize={30}> <ResizablePanel defaultSize={layout[1]} minSize={25}>
<div className="h-full overflow-hidden">{props.list}</div> <div className="h-full overflow-hidden">{props.list}</div>
</ResizablePanel> </ResizablePanel>
<ResizableHandle withHandle /> <ResizableHandle withHandle />
<ResizablePanel defaultSize={layout[2]}> <ResizablePanel defaultSize={layout[2]}>
<div> <div className="h-full overflow-hidden">
<Outlet /> <Outlet />
</div> </div>
</ResizablePanel> </ResizablePanel>

View File

@ -15,6 +15,7 @@ import { Route as WebsiteImport } from './routes/website'
import { Route as RegisterImport } from './routes/register' import { Route as RegisterImport } from './routes/register'
import { Route as LoginImport } from './routes/login' import { Route as LoginImport } from './routes/login'
import { Route as IndexImport } from './routes/index' import { Route as IndexImport } from './routes/index'
import { Route as WebsiteAddImport } from './routes/website/add'
import { Route as WebsiteWebsiteIdImport } from './routes/website/$websiteId' import { Route as WebsiteWebsiteIdImport } from './routes/website/$websiteId'
// Create/Update Routes // Create/Update Routes
@ -39,6 +40,11 @@ const IndexRoute = IndexImport.update({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const WebsiteAddRoute = WebsiteAddImport.update({
path: '/add',
getParentRoute: () => WebsiteRoute,
} as any)
const WebsiteWebsiteIdRoute = WebsiteWebsiteIdImport.update({ const WebsiteWebsiteIdRoute = WebsiteWebsiteIdImport.update({
path: '/$websiteId', path: '/$websiteId',
getParentRoute: () => WebsiteRoute, getParentRoute: () => WebsiteRoute,
@ -68,6 +74,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WebsiteWebsiteIdImport preLoaderRoute: typeof WebsiteWebsiteIdImport
parentRoute: typeof WebsiteImport parentRoute: typeof WebsiteImport
} }
'/website/add': {
preLoaderRoute: typeof WebsiteAddImport
parentRoute: typeof WebsiteImport
}
} }
} }
@ -77,7 +87,7 @@ export const routeTree = rootRoute.addChildren([
IndexRoute, IndexRoute,
LoginRoute, LoginRoute,
RegisterRoute, RegisterRoute,
WebsiteRoute.addChildren([WebsiteWebsiteIdRoute]), WebsiteRoute.addChildren([WebsiteWebsiteIdRoute, WebsiteAddRoute]),
]) ])
/* prettier-ignore-end */ /* prettier-ignore-end */

View File

@ -1,20 +1,20 @@
import { trpc } from '@/api/trpc'; import { trpc } from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader';
import { CommonList } from '@/components/CommonList'; import { CommonList } from '@/components/CommonList';
import { CommonSidebar } from '@/components/CommonSidebar'; import { CommonWrapper } from '@/components/CommonWrapper';
import { Separator } from '@/components/ui/separator'; import { Button } from '@/components/ui/button';
import { AddWebsiteBtn } from '@/components/website/AddWebsiteBtn';
import { useDataReady } from '@/hooks/useDataReady'; import { useDataReady } from '@/hooks/useDataReady';
import { useEvent } from '@/hooks/useEvent';
import { LayoutV2 } from '@/pages/LayoutV2'; import { LayoutV2 } from '@/pages/LayoutV2';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
import { import {
createFileRoute, createFileRoute,
getRouteApi,
useNavigate, useNavigate,
useRouterState,
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import { LuPlus } from 'react-icons/lu';
const routeApi = getRouteApi('/website/$websiteId');
export const Route = createFileRoute('/website')({ export const Route = createFileRoute('/website')({
beforeLoad: routeAuthBeforeLoad, beforeLoad: routeAuthBeforeLoad,
@ -28,6 +28,9 @@ function WebsiteComponent() {
workspaceId, workspaceId,
}); });
const navigate = useNavigate(); const navigate = useNavigate();
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
const items = data.map((item) => ({ const items = data.map((item) => ({
id: item.id, id: item.id,
@ -36,12 +39,11 @@ function WebsiteComponent() {
tags: [], tags: [],
href: `/website/${item.id}`, href: `/website/${item.id}`,
})); }));
const params = routeApi.useParams<{ websiteId: string }>();
useDataReady( useDataReady(
() => data.length > 0, () => data.length > 0,
() => { () => {
if (!params.websiteId && data[0]) { if (pathname === Route.fullPath) {
navigate({ navigate({
to: '/website/$websiteId', to: '/website/$websiteId',
params: { params: {
@ -52,22 +54,33 @@ function WebsiteComponent() {
} }
); );
const handleClickAdd = useEvent(() => {
navigate({
to: '/website/add',
});
});
return ( return (
<LayoutV2 <LayoutV2
list={ list={
<CommonSidebar <CommonWrapper
header={ header={
<> <CommonHeader
<h1 className="text-xl font-bold">{t('Website')}</h1> title={t('Website')}
actions={
<div className="ml-auto"> <Button
<AddWebsiteBtn /> variant="outline"
</div> Icon={LuPlus}
</> onClick={handleClickAdd}
>
{t('Add')}
</Button>
}
/>
} }
> >
<CommonList hasSearch={true} items={items} /> <CommonList hasSearch={true} items={items} />
</CommonSidebar> </CommonWrapper>
} }
/> />
); );

View File

@ -1,5 +1,22 @@
import { trpc } from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader';
import { CommonWrapper } from '@/components/CommonWrapper';
import { ErrorTip } from '@/components/ErrorTip';
import { Loading } from '@/components/Loading';
import { NotFoundTip } from '@/components/NotFoundTip';
import { Button } from '@/components/ui/button';
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { WebsiteCodeBtn } from '@/components/website/WebsiteCodeBtn';
import { WebsiteMetricsTable } from '@/components/website/WebsiteMetricsTable';
import { WebsiteOverview } from '@/components/website/WebsiteOverview';
import { WebsiteVisitorMapBtn } from '@/components/website/WebsiteVisitorMapBtn';
import { useGlobalRangeDate } from '@/hooks/useGlobalRangeDate';
import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route'; import { routeAuthBeforeLoad } from '@/utils/route';
import { createFileRoute, getRouteApi } from '@tanstack/react-router'; import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Card } from 'antd';
import { LuArrowRight } from 'react-icons/lu';
export const Route = createFileRoute('/website/$websiteId')({ export const Route = createFileRoute('/website/$websiteId')({
beforeLoad: routeAuthBeforeLoad, beforeLoad: routeAuthBeforeLoad,
@ -7,7 +24,119 @@ export const Route = createFileRoute('/website/$websiteId')({
}); });
function WebsiteDetailComponent() { function WebsiteDetailComponent() {
const params = Route.useParams<{ websiteId: string }>(); const { websiteId } = Route.useParams<{ websiteId: string }>();
const { t } = useTranslation();
const workspaceId = useCurrentWorkspaceId();
const { data: website, isLoading } = trpc.website.info.useQuery({
workspaceId,
websiteId,
});
const { startDate, endDate } = useGlobalRangeDate();
return <div>website: {params.websiteId}</div>; if (!websiteId) {
return <ErrorTip />;
}
if (isLoading) {
return <Loading />;
}
if (!website) {
return <NotFoundTip />;
}
const startAt = startDate.unix() * 1000;
const endAt = endDate.unix() * 1000;
return (
<CommonWrapper
header={
<CommonHeader
title={website.name}
actions={
<>
<WebsiteCodeBtn websiteId={website.id} />
</>
}
/>
}
>
<ScrollArea className="h-full overflow-hidden p-4">
<ScrollBar orientation="horizontal" />
<Card>
<Card.Grid hoverable={false} className="!w-full">
<WebsiteOverview website={website} showDateFilter={true} />
</Card.Grid>
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/2">
<WebsiteMetricsTable
websiteId={websiteId}
type="url"
title={[t('Pages'), t('Views')]}
startAt={startAt}
endAt={endAt}
/>
</Card.Grid>
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/2">
<WebsiteMetricsTable
websiteId={websiteId}
type="referrer"
title={[t('Referrers'), t('Views')]}
startAt={startAt}
endAt={endAt}
/>
</Card.Grid>
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/3">
<WebsiteMetricsTable
websiteId={websiteId}
type="browser"
title={[t('Browser'), t('Visitors')]}
startAt={startAt}
endAt={endAt}
/>
</Card.Grid>
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/3">
<WebsiteMetricsTable
websiteId={websiteId}
type="os"
title={[t('OS'), t('Visitors')]}
startAt={startAt}
endAt={endAt}
/>
</Card.Grid>
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/3">
<WebsiteMetricsTable
websiteId={websiteId}
type="device"
title={[t('Devices'), t('Visitors')]}
startAt={startAt}
endAt={endAt}
/>
</Card.Grid>
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/2">
<WebsiteMetricsTable
websiteId={websiteId}
type="country"
title={[t('Countries'), t('Visitors')]}
startAt={startAt}
endAt={endAt}
/>
<div className="mt-2 text-center">
<WebsiteVisitorMapBtn websiteId={websiteId} />
</div>
</Card.Grid>
<Card.Grid hoverable={false} className="min-h-[470px] !w-1/2">
<WebsiteMetricsTable
websiteId={websiteId}
type="event"
title={[t('Events'), t('Actions')]}
startAt={startAt}
endAt={endAt}
/>
</Card.Grid>
</Card>
</ScrollArea>
</CommonWrapper>
);
} }

View File

@ -0,0 +1,119 @@
import { routeAuthBeforeLoad } from '@/utils/route';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { t, useTranslation } from '@i18next-toolkit/react';
import { Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { useEvent } from '@/hooks/useEvent';
import { Input } from '@/components/ui/input';
import { useCurrentWorkspaceId } from '@/store/user';
import { trpc } from '@/api/trpc';
import { hostnameRegex } from '@tianji/shared';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { CommonWrapper } from '@/components/CommonWrapper';
export const Route = createFileRoute('/website/add')({
beforeLoad: routeAuthBeforeLoad,
component: WebsiteDetailComponent,
});
const addFormSchema = z.object({
name: z.string(),
domain: z.union([z.string().ip(), z.string().regex(hostnameRegex)]),
});
function WebsiteDetailComponent() {
const { t } = useTranslation();
const workspaceId = useCurrentWorkspaceId();
const addWebsiteMutation = trpc.website.add.useMutation();
const utils = trpc.useUtils();
const navigate = useNavigate();
const form = useForm<z.infer<typeof addFormSchema>>({
resolver: zodResolver(addFormSchema),
defaultValues: {
name: '',
domain: '',
},
});
const onSubmit = useEvent(async (values: z.infer<typeof addFormSchema>) => {
const res = await addWebsiteMutation.mutateAsync({
workspaceId,
name: values.name,
domain: values.domain,
});
utils.website.all.refetch();
form.reset();
navigate({
to: '/website/$websiteId',
params: {
websiteId: res.id,
},
});
});
return (
<CommonWrapper
header={<h1 className="text-xl font-bold">{t('Add Website')}</h1>}
>
<div className="p-4">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<Card>
<CardContent className="pt-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('Website Name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('Website Name to Display')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="domain"
render={({ field }) => (
<FormItem>
<FormLabel>{t('Domain')}</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter>
<Button type="submit" loading={addWebsiteMutation.isLoading}>
{t('Create')}
</Button>
</CardFooter>
</Card>
</form>
</Form>
</div>
</CommonWrapper>
);
}