Merge branch 'dev' into master

This commit is contained in:
ABHISHEK 2025-10-03 07:19:04 +05:30 committed by GitHub
commit 05cc18882c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1113 changed files with 50574 additions and 28130 deletions

View file

@ -0,0 +1,68 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import { notFound } from '@/lib/response';
import redis from '@/lib/redis';
import { findPixel } from '@/queries/prisma';
import { Pixel } from '@/generated/prisma/client';
import { POST } from '@/app/api/send/route';
const image = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw', 'base64');
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let pixel: Pixel;
if (redis.enabled) {
pixel = await redis.client.fetch(
`pixel:${slug}`,
async () => {
return findPixel({
where: {
slug,
},
});
},
86400,
);
if (!pixel) {
return notFound();
}
} else {
pixel = await findPixel({
where: {
slug,
},
});
if (!pixel) {
return notFound();
}
}
const payload = {
type: 'event',
payload: {
pixel: pixel.id,
url: request.url,
referrer: request.headers.get('referer'),
},
};
const req = new Request(request.url, {
method: 'POST',
headers: request.headers,
body: JSON.stringify(payload),
});
await POST(req);
return new NextResponse(image, {
headers: {
'Content-Type': 'image/gif',
'Content-Length': image.length.toString(),
},
});
}

View file

@ -0,0 +1,61 @@
export const dynamic = 'force-dynamic';
import { NextResponse } from 'next/server';
import { notFound } from '@/lib/response';
import { findLink } from '@/queries/prisma';
import { POST } from '@/app/api/send/route';
import { Link } from '@/generated/prisma/client';
import redis from '@/lib/redis';
export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
let link: Link;
if (redis.enabled) {
link = await redis.client.fetch(
`link:${slug}`,
async () => {
return findLink({
where: {
slug,
},
});
},
86400,
);
if (!link) {
return notFound();
}
} else {
link = await findLink({
where: {
slug,
},
});
if (!link) {
return notFound();
}
}
const payload = {
type: 'event',
payload: {
link: link.id,
url: request.url,
referrer: request.headers.get('referer'),
},
};
const req = new Request(request.url, {
method: 'POST',
headers: request.headers,
body: JSON.stringify(payload),
});
await POST(req);
return NextResponse.redirect(link.url);
}

View file

@ -1,21 +1,26 @@
'use client';
import { Loading } from 'react-basics';
import { Grid, Loading, Column } from '@umami/react-zen';
import Script from 'next/script';
import { usePathname } from 'next/navigation';
import { useLogin, useConfig } from '@/components/hooks';
import UpdateNotice from './UpdateNotice';
import { UpdateNotice } from './UpdateNotice';
import { SideNav } from '@/app/(main)/SideNav';
import { useLoginQuery, useConfig, useNavigation } from '@/components/hooks';
export function App({ children }) {
const { user, isLoading, error } = useLogin();
const { user, isLoading, error } = useLoginQuery();
const config = useConfig();
const pathname = usePathname();
const { pathname, router } = useNavigation();
if (isLoading) {
return <Loading />;
if (isLoading || !config) {
return <Loading placement="absolute" />;
}
if (error) {
window.location.href = `${process.env.basePath || ''}/login`;
if (process.env.cloudMode) {
window.location.href = '/login';
} else {
router.push('/login');
}
return null;
}
if (!user || !config) {
@ -23,14 +28,17 @@ export function App({ children }) {
}
return (
<>
{children}
<Grid height="100vh" width="100%" columns="auto 1fr" backgroundColor="2">
<Column>
<SideNav />
</Column>
<Column alignItems="center" overflow="auto" position="relative">
{children}
</Column>
<UpdateNotice user={user} config={config} />
{process.env.NODE_ENV === 'production' && !pathname.includes('/share/') && (
<Script src={`${process.env.basePath || ''}/telemetry.js`} />
)}
</>
</Grid>
);
}
export default App;

View file

@ -1,76 +0,0 @@
.navbar {
display: grid;
grid-template-columns: max-content 1fr 1fr;
position: relative;
align-items: center;
height: 60px;
background: var(--base75);
border-bottom: 1px solid var(--base300);
padding: 0 20px;
z-index: 200;
}
.logo {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 700;
min-width: 0;
}
.links {
display: flex;
flex-direction: row;
gap: 30px;
padding: 0 40px;
font-weight: 700;
max-height: 60px;
align-items: center;
}
.links a,
.links a:active,
.links a:visited {
color: var(--font-color200);
line-height: 60px;
border-bottom: 2px solid transparent;
}
.links a:hover {
color: var(--font-color100);
border-bottom: 2px solid var(--primary400);
}
.links a.selected {
color: var(--font-color100);
border-bottom: 2px solid var(--primary400);
}
.actions,
.mobile {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
}
.mobile {
display: none;
}
@media only screen and (max-width: 768px) {
.navbar {
grid-template-columns: repeat(2, 1fr);
}
.links,
.actions {
display: none;
}
.mobile {
display: flex;
}
}

View file

@ -1,133 +0,0 @@
'use client';
import { useEffect } from 'react';
import { Icon, Text } from 'react-basics';
import Link from 'next/link';
import classNames from 'classnames';
import HamburgerButton from '@/components/common/HamburgerButton';
import ThemeButton from '@/components/input/ThemeButton';
import LanguageButton from '@/components/input/LanguageButton';
import ProfileButton from '@/components/input/ProfileButton';
import TeamsButton from '@/components/input/TeamsButton';
import Icons from '@/components/icons';
import { useMessages, useNavigation, useTeamUrl } from '@/components/hooks';
import { getItem, setItem } from '@/lib/storage';
import styles from './NavBar.module.css';
export function NavBar() {
const { formatMessage, labels } = useMessages();
const { pathname, router } = useNavigation();
const { teamId, renderTeamUrl } = useTeamUrl();
const cloudMode = !!process.env.cloudMode;
const links = [
{ label: formatMessage(labels.dashboard), url: renderTeamUrl('/dashboard') },
{ label: formatMessage(labels.websites), url: renderTeamUrl('/websites') },
{ label: formatMessage(labels.reports), url: renderTeamUrl('/reports') },
{ label: formatMessage(labels.settings), url: renderTeamUrl('/settings') },
].filter(n => n);
const menuItems = [
{
label: formatMessage(labels.dashboard),
url: renderTeamUrl('/dashboard'),
},
!cloudMode && {
label: formatMessage(labels.settings),
url: renderTeamUrl('/settings'),
children: [
...(teamId
? [
{
label: formatMessage(labels.team),
url: renderTeamUrl('/settings/team'),
},
]
: []),
{
label: formatMessage(labels.websites),
url: renderTeamUrl('/settings/websites'),
},
...(!teamId
? [
{
label: formatMessage(labels.teams),
url: renderTeamUrl('/settings/teams'),
},
{
label: formatMessage(labels.users),
url: '/settings/users',
},
]
: [
{
label: formatMessage(labels.members),
url: renderTeamUrl('/settings/members'),
},
]),
],
},
{
label: formatMessage(labels.profile),
url: '/profile',
},
!cloudMode && { label: formatMessage(labels.logout), url: '/logout' },
].filter(n => n);
const handleTeamChange = (teamId: string) => {
const url = teamId ? `/teams/${teamId}` : '/';
if (!cloudMode) {
setItem('umami.team', { id: teamId });
}
router.push(cloudMode ? `${process.env.cloudUrl}${url}` : url);
};
useEffect(() => {
if (!cloudMode) {
const teamIdLocal = getItem('umami.team')?.id;
if (teamIdLocal && teamIdLocal !== teamId) {
router.push(
pathname !== '/' && pathname !== '/dashboard' ? '/' : `/teams/${teamIdLocal}/dashboard`,
);
}
}
}, [cloudMode]);
return (
<div className={styles.navbar}>
<div className={styles.logo}>
<Icon size="lg">
<Icons.Logo />
</Icon>
<Text>umami</Text>
</div>
<div className={styles.links}>
{links.map(({ url, label }) => {
return (
<Link
key={url}
href={url}
className={classNames({ [styles.selected]: pathname.startsWith(url) })}
prefetch={url !== '/settings'}
>
<Text>{label}</Text>
</Link>
);
})}
</div>
<div className={styles.actions}>
<TeamsButton onChange={handleTeamChange} />
<ThemeButton />
<LanguageButton />
<ProfileButton />
</div>
<div className={styles.mobile}>
<TeamsButton onChange={handleTeamChange} showText={false} />
<HamburgerButton menuItems={menuItems} />
</div>
</div>
);
}
export default NavBar;

View file

@ -0,0 +1,89 @@
import { Key } from 'react';
import Link from 'next/link';
import {
Sidebar,
SidebarSection,
SidebarItem,
SidebarHeader,
Row,
SidebarProps,
ThemeButton,
} from '@umami/react-zen';
import { Globe, LinkIcon, Grid2x2, PanelLeft } from '@/components/icons';
import { Logo } from '@/components/svg';
import { useMessages, useNavigation, useGlobalState } from '@/components/hooks';
import { NavButton } from '@/components/input/NavButton';
import { PanelButton } from '@/components/input/PanelButton';
import { LanguageButton } from '@/components/input/LanguageButton';
export function SideNav(props: SidebarProps) {
const { formatMessage, labels } = useMessages();
const { pathname, renderUrl, websiteId, router } = useNavigation();
const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed');
const hasNav = !!(websiteId || pathname.startsWith('/admin') || pathname.includes('/settings'));
const links = [
{
id: 'websites',
label: formatMessage(labels.websites),
path: '/websites',
icon: <Globe />,
},
{
id: 'links',
label: formatMessage(labels.links),
path: '/links',
icon: <LinkIcon />,
},
{
id: 'pixels',
label: formatMessage(labels.pixels),
path: '/pixels',
icon: <Grid2x2 />,
},
];
const handleSelect = (id: Key) => {
router.push(id === 'user' ? '/websites' : `/teams/${id}/websites`);
};
return (
<Row height="100%" backgroundColor>
<Sidebar {...props} isCollapsed={isCollapsed || hasNav}>
<SidebarSection onClick={() => setIsCollapsed(false)}>
<SidebarHeader
label="umami"
icon={isCollapsed && !hasNav ? <PanelLeft /> : <Logo />}
style={{ maxHeight: 40 }}
>
{!isCollapsed && !hasNav && <PanelButton />}
</SidebarHeader>
</SidebarSection>
<SidebarSection paddingTop="0" paddingBottom="0" justifyContent="center">
<NavButton showText={!hasNav && !isCollapsed} onAction={handleSelect} />
</SidebarSection>
<SidebarSection flexGrow={1}>
{links.map(({ id, path, label, icon }) => {
return (
<Link key={id} href={renderUrl(path, false)} role="button">
<SidebarItem
label={label}
icon={icon}
isSelected={pathname.includes(path)}
role="button"
/>
</Link>
);
})}
</SidebarSection>
<SidebarSection justifyContent="flex-start">
<Row wrap="wrap">
<LanguageButton />
<ThemeButton />
</Row>
</SidebarSection>
</Sidebar>
</Row>
);
}

26
src/app/(main)/TopNav.tsx Normal file
View file

@ -0,0 +1,26 @@
import { ThemeButton, Row } from '@umami/react-zen';
import { LanguageButton } from '@/components/input/LanguageButton';
import { ProfileButton } from '@/components/input/ProfileButton';
export function TopNav() {
return (
<Row
position="absolute"
top="0"
alignItems="center"
justifyContent="flex-end"
paddingY="2"
paddingX="3"
paddingRight="5"
width="100%"
style={{ position: 'sticky', top: 0 }}
zIndex={1}
>
<Row alignItems="center" justifyContent="flex-end" backgroundColor="2" borderRadius>
<ThemeButton />
<LanguageButton />
<ProfileButton />
</Row>
</Row>
);
}

View file

@ -1,38 +0,0 @@
.notice {
position: absolute;
display: flex;
justify-content: space-between;
width: 100%;
max-width: 800px;
gap: 20px;
margin: 60px auto;
align-self: center;
background: var(--base50);
padding: 20px;
border: 1px solid var(--base300);
border-radius: var(--border-radius);
z-index: 9999;
box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.1);
}
.message {
display: flex;
justify-content: center;
align-items: center;
color: var(--font-color100);
font-weight: 700;
}
.buttons {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 10px;
flex: 0;
}
@media only screen and (max-width: 992px) {
.message {
height: 80px;
}
}

View file

@ -1,16 +1,14 @@
import { useEffect, useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button } from 'react-basics';
import { Button, AlertBanner, Flexbox } from '@umami/react-zen';
import { setItem } from '@/lib/storage';
import useStore, { checkVersion } from '@/store/version';
import { useVersion, checkVersion } from '@/store/version';
import { REPO_URL, VERSION_CHECK } from '@/lib/constants';
import { useMessages } from '@/components/hooks';
import { usePathname } from 'next/navigation';
import styles from './UpdateNotice.module.css';
export function UpdateNotice({ user, config }) {
const { formatMessage, labels, messages } = useMessages();
const { latest, checked, hasUpdate, releaseUrl } = useStore();
const { latest, checked, hasUpdate, releaseUrl } = useVersion();
const pathname = usePathname();
const [dismissed, setDismissed] = useState(checked);
@ -48,20 +46,14 @@ export function UpdateNotice({ user, config }) {
return null;
}
return createPortal(
<div className={styles.notice}>
<div className={styles.message}>
{formatMessage(messages.newVersionAvailable, { version: `v${latest}` })}
</div>
<div className={styles.buttons}>
<Button variant="primary" onClick={handleViewClick}>
return (
<Flexbox justifyContent="space-between" alignItems="center">
<AlertBanner title={formatMessage(messages.newVersionAvailable, { version: `v${latest}` })}>
<Button variant="primary" onPress={handleViewClick}>
{formatMessage(labels.viewDetails)}
</Button>
<Button onClick={handleDismissClick}>{formatMessage(labels.dismiss)}</Button>
</div>
</div>,
document.body,
<Button onPress={handleDismissClick}>{formatMessage(labels.dismiss)}</Button>
</AlertBanner>
</Flexbox>
);
}
export default UpdateNotice;

View file

@ -0,0 +1,61 @@
'use client';
import { ReactNode } from 'react';
import { Grid, Column } from '@umami/react-zen';
import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks';
import { User, Users, Globe } from '@/components/icons';
import { SideMenu } from '@/components/common/SideMenu';
import { PageBody } from '@/components/common/PageBody';
export function AdminLayout({ children }: { children: ReactNode }) {
const { user } = useLoginQuery();
const { formatMessage, labels } = useMessages();
const { pathname } = useNavigation();
if (!user.isAdmin || process.env.cloudMode) {
return null;
}
const items = [
{
label: formatMessage(labels.manage),
items: [
{
id: 'users',
label: formatMessage(labels.users),
path: '/admin/users',
icon: <User />,
},
{
id: 'websites',
label: formatMessage(labels.websites),
path: '/admin/websites',
icon: <Globe />,
},
{
id: 'teams',
label: formatMessage(labels.teams),
path: '/admin/teams',
icon: <Users />,
},
],
},
];
const selectedKey = items
.flatMap(e => e.items)
?.find(({ path }) => path && pathname.startsWith(path))?.id;
return (
<Grid columns="auto 1fr" width="100%" height="100%">
<Column height="100%" border="right" backgroundColor>
<SideMenu
items={items}
title={formatMessage(labels.admin)}
selectedKey={selectedKey}
allowMinimize={false}
/>
</Column>
<PageBody>{children}</PageBody>
</Grid>
);
}

View file

@ -0,0 +1,17 @@
import { Metadata } from 'next';
import { AdminLayout } from './AdminLayout';
export default function ({ children }) {
if (process.env.cloudMode) {
return null;
}
return <AdminLayout>{children}</AdminLayout>;
}
export const metadata: Metadata = {
title: {
template: '%s | Admin | Umami',
default: 'Admin | Umami',
},
};

View file

@ -0,0 +1,19 @@
import { DataGrid } from '@/components/common/DataGrid';
import { useTeamsQuery } from '@/components/hooks';
import { AdminTeamsTable } from './AdminTeamsTable';
import { ReactNode } from 'react';
export function AdminTeamsDataTable({
showActions,
}: {
showActions?: boolean;
children?: ReactNode;
}) {
const queryResult = useTeamsQuery();
return (
<DataGrid query={queryResult} allowSearch={true}>
{({ data }) => <AdminTeamsTable data={data} showActions={showActions} />}
</DataGrid>
);
}

View file

@ -0,0 +1,19 @@
'use client';
import { AdminTeamsDataTable } from './AdminTeamsDataTable';
import { Column } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
export function AdminTeamsPage() {
const { formatMessage, labels } = useMessages();
return (
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.teams)} />
<Panel>
<AdminTeamsDataTable />
</Panel>
</Column>
);
}

View file

@ -0,0 +1,82 @@
import { useState } from 'react';
import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal } from '@umami/react-zen';
import Link from 'next/link';
import { Trash } from '@/components/icons';
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
import { DateDistance } from '@/components/common/DateDistance';
export function AdminTeamsTable({
data = [],
showActions = true,
}: {
data: any[];
showActions?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const [deleteUser, setDeleteUser] = useState(null);
return (
<>
<DataTable data={data}>
<DataColumn id="name" label={formatMessage(labels.name)} width="1fr">
{(row: any) => <Link href={`/admin/teams/${row.id}`}>{row.name}</Link>}
</DataColumn>
<DataColumn id="websites" label={formatMessage(labels.members)} width="140px">
{(row: any) => row?._count?.members}
</DataColumn>
<DataColumn id="members" label={formatMessage(labels.websites)} width="140px">
{(row: any) => row?._count?.websites}
</DataColumn>
<DataColumn id="owner" label={formatMessage(labels.owner)}>
{(row: any) => {
const name = row?.members?.[0]?.user?.username;
return (
<Text title={name} truncate>
<Link href={`/admin/users/${row?.members?.[0]?.user?.id}`}>{name}</Link>
</Text>
);
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)} width="160px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
<DataColumn id="action" align="end" width="50px">
{(row: any) => {
const { id } = row;
return (
<MenuButton>
<MenuItem href={`/admin/teams/${id}`} data-test="link-button-edit">
<Row alignItems="center" gap>
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</Row>
</MenuItem>
<MenuItem
id="delete"
onAction={() => setDeleteUser(row)}
data-test="link-button-delete"
>
<Row alignItems="center" gap>
<Icon>
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</Row>
</MenuItem>
</MenuButton>
);
}}
</DataColumn>
)}
</DataTable>
<Modal isOpen={!!deleteUser}></Modal>
</>
);
}

View file

@ -0,0 +1,11 @@
'use client';
import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings';
import { TeamProvider } from '@/app/(main)/teams/TeamProvider';
export function AdminTeamPage({ teamId }: { teamId: string }) {
return (
<TeamProvider teamId={teamId}>
<TeamSettings teamId={teamId} />
</TeamProvider>
);
}

View file

@ -0,0 +1,12 @@
import { AdminTeamPage } from './AdminTeamPage';
import { Metadata } from 'next';
export default async function ({ params }: { params: Promise<{ teamId: string }> }) {
const { teamId } = await params;
return <AdminTeamPage teamId={teamId} />;
}
export const metadata: Metadata = {
title: 'Team',
};

View file

@ -0,0 +1,9 @@
import { Metadata } from 'next';
import { AdminTeamsPage } from './AdminTeamsPage';
export default function () {
return <AdminTeamsPage />;
}
export const metadata: Metadata = {
title: 'Teams',
};

View file

@ -0,0 +1,32 @@
import { Button, Icon, Text, Modal, DialogTrigger, Dialog, useToast } from '@umami/react-zen';
import { UserAddForm } from './UserAddForm';
import { useMessages, useModified } from '@/components/hooks';
import { Plus } from '@/components/icons';
export function UserAddButton({ onSave }: { onSave?: () => void }) {
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const handleSave = () => {
toast(formatMessage(messages.saved));
touch('users');
onSave?.();
};
return (
<DialogTrigger>
<Button variant="primary" data-test="button-create-user">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.createUser)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.createUser)} style={{ width: 400 }}>
{({ close }) => <UserAddForm onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -0,0 +1,71 @@
import {
Select,
ListItem,
Form,
FormField,
FormButtons,
FormSubmitButton,
TextField,
PasswordField,
Button,
} from '@umami/react-zen';
import { useMessages, useUpdateQuery } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
export function UserAddForm({ onSave, onClose }) {
const { mutateAsync, error, isPending } = useUpdateQuery(`/users`);
const { formatMessage, labels, getErrorMessage } = useMessages();
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
onSave(data);
onClose();
},
});
};
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)}>
<FormField
label={formatMessage(labels.username)}
name="username"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="new-username" data-test="input-username" />
</FormField>
<FormField
label={formatMessage(labels.password)}
name="password"
rules={{ required: formatMessage(labels.required) }}
>
<PasswordField autoComplete="new-password" data-test="input-password" />
</FormField>
<FormField
label={formatMessage(labels.role)}
name="role"
rules={{ required: formatMessage(labels.required) }}
>
<Select>
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
{formatMessage(labels.viewOnly)}
</ListItem>
<ListItem id={ROLES.user} data-test="dropdown-item-user">
{formatMessage(labels.user)}
</ListItem>
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
{formatMessage(labels.admin)}
</ListItem>
</Select>
</FormField>
<FormButtons>
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
<FormSubmitButton variant="primary" data-test="button-submit" isDisabled={false}>
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -0,0 +1,35 @@
import { Button, Icon, Modal, DialogTrigger, Dialog, Text } from '@umami/react-zen';
import { useMessages, useLoginQuery } from '@/components/hooks';
import { Trash } from '@/components/icons';
import { UserDeleteForm } from './UserDeleteForm';
export function UserDeleteButton({
userId,
username,
onDelete,
}: {
userId: string;
username: string;
onDelete?: () => void;
}) {
const { formatMessage, labels } = useMessages();
const { user } = useLoginQuery();
return (
<DialogTrigger>
<Button isDisabled={userId === user?.id} data-test="button-delete">
<Icon size="sm">
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.deleteUser)} style={{ width: 400 }}>
{({ close }) => (
<UserDeleteForm userId={userId} username={username} onSave={onDelete} onClose={close} />
)}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -0,0 +1,41 @@
import { AlertDialog, Row } from '@umami/react-zen';
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
export function UserDeleteForm({
userId,
username,
onSave,
onClose,
}: {
userId: string;
username: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { messages, labels, formatMessage } = useMessages();
const { mutateAsync } = useDeleteQuery(`/users/${userId}`);
const { touch } = useModified();
const handleConfirm = async () => {
await mutateAsync(null, {
onSuccess: async () => {
touch('users');
touch(`users:${userId}`);
onSave?.();
onClose?.();
},
});
};
return (
<AlertDialog
title={formatMessage(labels.delete)}
onConfirm={handleConfirm}
onCancel={onClose}
confirmLabel={formatMessage(labels.delete)}
isDanger
>
<Row gap="1">{formatMessage(messages.confirmDelete, { target: username })}</Row>
</AlertDialog>
);
}

View file

@ -0,0 +1,14 @@
import { DataGrid } from '@/components/common/DataGrid';
import { useUsersQuery } from '@/components/hooks';
import { UsersTable } from './UsersTable';
import { ReactNode } from 'react';
export function UsersDataTable({ showActions }: { showActions?: boolean; children?: ReactNode }) {
const queryResult = useUsersQuery();
return (
<DataGrid query={queryResult} allowSearch={true}>
{({ data }) => <UsersTable data={data} showActions={showActions} />}
</DataGrid>
);
}

View file

@ -0,0 +1,24 @@
'use client';
import { UsersDataTable } from './UsersDataTable';
import { Column } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { UserAddButton } from './UserAddButton';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
export function UsersPage() {
const { formatMessage, labels } = useMessages();
const handleSave = () => {};
return (
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.users)}>
<UserAddButton onSave={handleSave} />
</PageHeader>
<Panel>
<UsersDataTable />
</Panel>
</Column>
);
}

View file

@ -0,0 +1,85 @@
import { useState } from 'react';
import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal } from '@umami/react-zen';
import Link from 'next/link';
import { ROLES } from '@/lib/constants';
import { Trash } from '@/components/icons';
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
import { UserDeleteForm } from './UserDeleteForm';
import { DateDistance } from '@/components/common/DateDistance';
export function UsersTable({
data = [],
showActions = true,
}: {
data: any[];
showActions?: boolean;
}) {
const { formatMessage, labels } = useMessages();
const [deleteUser, setDeleteUser] = useState(null);
return (
<>
<DataTable data={data}>
<DataColumn id="username" label={formatMessage(labels.username)} width="2fr">
{(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
</DataColumn>
<DataColumn id="role" label={formatMessage(labels.role)}>
{(row: any) =>
formatMessage(
labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown,
)
}
</DataColumn>
<DataColumn id="websites" label={formatMessage(labels.websites)}>
{(row: any) => row._count.websites}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
<DataColumn id="action" align="end" width="100px">
{(row: any) => {
const { id } = row;
return (
<MenuButton>
<MenuItem href={`/admin/users/${id}`} data-test="link-button-edit">
<Row alignItems="center" gap>
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</Row>
</MenuItem>
<MenuItem
id="delete"
onAction={() => setDeleteUser(row)}
data-test="link-button-delete"
>
<Row alignItems="center" gap>
<Icon>
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</Row>
</MenuItem>
</MenuButton>
);
}}
</DataColumn>
)}
</DataTable>
<Modal isOpen={!!deleteUser}>
<UserDeleteForm
userId={deleteUser?.id}
username={deleteUser?.username}
onClose={() => {
setDeleteUser(null);
}}
/>
</Modal>
</>
);
}

View file

@ -0,0 +1,72 @@
import {
Select,
ListItem,
Form,
FormField,
FormButtons,
TextField,
FormSubmitButton,
PasswordField,
} from '@umami/react-zen';
import { useLoginQuery, useMessages, useUpdateQuery, useUser } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
export function UserEditForm({ userId, onSave }: { userId: string; onSave?: () => void }) {
const { formatMessage, labels, messages, getMessage } = useMessages();
const user = useUser();
const { user: login } = useLoginQuery();
const { mutateAsync, error, toast, touch } = useUpdateQuery(`/users/${userId}`);
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch(`user:${user.id}`);
onSave?.();
},
});
};
return (
<Form onSubmit={handleSubmit} error={getMessage(error?.['code'])} values={user}>
<FormField name="username" label={formatMessage(labels.username)}>
<TextField data-test="input-username" />
</FormField>
<FormField
name="password"
label={formatMessage(labels.password)}
rules={{
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) },
}}
>
<PasswordField autoComplete="new-password" data-test="input-password" />
</FormField>
{user.id !== login.id && (
<FormField
name="role"
label={formatMessage(labels.role)}
rules={{ required: formatMessage(labels.required) }}
>
<Select defaultSelectedKey={user.role}>
<ListItem id={ROLES.viewOnly} data-test="dropdown-item-viewOnly">
{formatMessage(labels.viewOnly)}
</ListItem>
<ListItem id={ROLES.user} data-test="dropdown-item-user">
{formatMessage(labels.user)}
</ListItem>
<ListItem id={ROLES.admin} data-test="dropdown-item-admin">
{formatMessage(labels.admin)}
</ListItem>
</Select>
</FormField>
)}
<FormButtons>
<FormSubmitButton data-test="button-submit" variant="primary">
{formatMessage(labels.save)}
</FormSubmitButton>
</FormButtons>
</Form>
);
}

View file

@ -0,0 +1,9 @@
import { User } from '@/components/icons';
import { PageHeader } from '@/components/common/PageHeader';
import { useUser } from '@/components/hooks';
export function UserHeader() {
const user = useUser();
return <PageHeader title={user?.username} icon={<User />} />;
}

View file

@ -0,0 +1,19 @@
'use client';
import { Column } from '@umami/react-zen';
import { UserSettings } from './UserSettings';
import { UserProvider } from './UserProvider';
import { UserHeader } from '@/app/(main)/admin/users/[userId]/UserHeader';
import { Panel } from '@/components/common/Panel';
export function UserPage({ userId }: { userId: string }) {
return (
<UserProvider userId={userId}>
<Column gap="6">
<UserHeader />
<Panel>
<UserSettings userId={userId} />
</Panel>
</Column>
</UserProvider>
);
}

View file

@ -0,0 +1,20 @@
import { createContext, ReactNode } from 'react';
import { Loading } from '@umami/react-zen';
import { User } from '@/generated/prisma/client';
import { useUserQuery } from '@/components/hooks/queries/useUserQuery';
export const UserContext = createContext<User>(null);
export function UserProvider({ userId, children }: { userId: string; children: ReactNode }) {
const { data: user, isFetching, isLoading } = useUserQuery(userId);
if (isFetching && isLoading) {
return <Loading placement="absolute" />;
}
if (!user) {
return null;
}
return <UserContext.Provider value={user}>{children}</UserContext.Provider>;
}

View file

@ -0,0 +1,25 @@
import { Column, Tabs, Tab, TabList, TabPanel } from '@umami/react-zen';
import { UserEditForm } from './UserEditForm';
import { useMessages } from '@/components/hooks';
import { UserWebsites } from './UserWebsites';
export function UserSettings({ userId }: { userId: string }) {
const { formatMessage, labels } = useMessages();
return (
<Column gap="6">
<Tabs>
<TabList>
<Tab id="details">{formatMessage(labels.details)}</Tab>
<Tab id="websites">{formatMessage(labels.websites)}</Tab>
</TabList>
<TabPanel id="details" style={{ width: 500 }}>
<UserEditForm userId={userId} />
</TabPanel>
<TabPanel id="websites">
<UserWebsites userId={userId} />
</TabPanel>
</Tabs>
</Column>
);
}

View file

@ -0,0 +1,15 @@
import { DataGrid } from '@/components/common/DataGrid';
import { useUserWebsitesQuery } from '@/components/hooks';
import { WebsitesTable } from '@/app/(main)/websites/WebsitesTable';
export function UserWebsites({ userId }) {
const queryResult = useUserWebsitesQuery({ userId });
return (
<DataGrid query={queryResult}>
{({ data }) => (
<WebsitesTable data={data} showActions={true} allowEdit={true} allowView={true} />
)}
</DataGrid>
);
}

View file

@ -1,12 +1,12 @@
import UserPage from './UserPage';
import { UserPage } from './UserPage';
import { Metadata } from 'next';
export default async function ({ params }: { params: { userId: string } }) {
export default async function ({ params }: { params: Promise<{ userId: string }> }) {
const { userId } = await params;
return <UserPage userId={userId} />;
}
export const metadata: Metadata = {
title: 'User Settings',
title: 'User',
};

View file

@ -1,8 +1,8 @@
import { Metadata } from 'next';
import UsersSettingsPage from './UsersSettingsPage';
import { UsersPage } from './UsersPage';
export default function () {
return <UsersSettingsPage />;
return <UsersPage />;
}
export const metadata: Metadata = {
title: 'Users',

View file

@ -0,0 +1,13 @@
import { DataGrid } from '@/components/common/DataGrid';
import { useWebsitesQuery } from '@/components/hooks';
import { AdminWebsitesTable } from './AdminWebsitesTable';
export function AdminWebsitesDataTable() {
const query = useWebsitesQuery();
return (
<DataGrid query={query} allowSearch={true}>
{props => <AdminWebsitesTable {...props} />}
</DataGrid>
);
}

View file

@ -0,0 +1,19 @@
'use client';
import { AdminWebsitesDataTable } from './AdminWebsitesDataTable';
import { Column } from '@umami/react-zen';
import { useMessages } from '@/components/hooks';
import { PageHeader } from '@/components/common/PageHeader';
import { Panel } from '@/components/common/Panel';
export function AdminWebsitesPage() {
const { formatMessage, labels } = useMessages();
return (
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.websites)} />
<Panel>
<AdminWebsitesDataTable />
</Panel>
</Column>
);
}

View file

@ -0,0 +1,90 @@
import { useState } from 'react';
import Link from 'next/link';
import { Row, Text, Icon, DataTable, DataColumn, MenuItem, Modal, Dialog } from '@umami/react-zen';
import { Trash, Users } from '@/components/icons';
import { useMessages } from '@/components/hooks';
import { Edit } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
import { DateDistance } from '@/components/common/DateDistance';
import { WebsiteDeleteForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';
export function AdminWebsitesTable({ data = [] }: { data: any[] }) {
const { formatMessage, labels } = useMessages();
const [deleteWebsite, setDeleteWebsite] = useState(null);
return (
<>
<DataTable data={data}>
<DataColumn id="name" label={formatMessage(labels.name)}>
{(row: any) => (
<Text truncate>
<Link href={`/admin/websites/${row.id}`}>{row.name}</Link>
</Text>
)}
</DataColumn>
<DataColumn id="domain" label={formatMessage(labels.domain)}>
{(row: any) => <Text truncate>{row.domain}</Text>}
</DataColumn>
<DataColumn id="owner" label={formatMessage(labels.owner)}>
{(row: any) => {
if (row?.team) {
return (
<Row alignItems="center" gap>
<Icon>
<Users />
</Icon>
<Text truncate>
<Link href={`/admin/teams/${row?.team?.id}`}>{row?.team?.name}</Link>
</Text>
</Row>
);
}
return (
<Text truncate>
<Link href={`/admin/users/${row?.user?.id}`}>{row?.user?.username}</Link>
</Text>
);
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)} width="180px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="50px">
{(row: any) => {
const { id } = row;
return (
<MenuButton>
<MenuItem href={`/admin/websites/${id}`} data-test="link-button-edit">
<Row alignItems="center" gap>
<Icon>
<Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</Row>
</MenuItem>
<MenuItem
id="delete"
onAction={() => setDeleteWebsite(id)}
data-test="link-button-delete"
>
<Row alignItems="center" gap>
<Icon>
<Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</Row>
</MenuItem>
</MenuButton>
);
}}
</DataColumn>
</DataTable>
<Modal isOpen={!!deleteWebsite}>
<Dialog style={{ width: 400 }}>
<WebsiteDeleteForm websiteId={deleteWebsite} onClose={() => setDeleteWebsite(null)} />
</Dialog>
</Modal>
</>
);
}

View file

@ -0,0 +1,14 @@
'use client';
import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings';
import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider';
import { Panel } from '@/components/common/Panel';
export function AdminWebsitePage({ websiteId }: { websiteId: string }) {
return (
<WebsiteProvider websiteId={websiteId}>
<Panel>
<WebsiteSettings websiteId={websiteId} />
</Panel>
</WebsiteProvider>
);
}

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { WebsiteSettingsPage } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage';
export default async function ({ params }: { params: Promise<{ websiteId: string }> }) {
const { websiteId } = await params;
return <WebsiteSettingsPage websiteId={websiteId} />;
}
export const metadata: Metadata = {
title: 'Website',
};

View file

@ -0,0 +1,9 @@
import { Metadata } from 'next';
import { AdminWebsitesPage } from './AdminWebsitesPage';
export default function () {
return <AdminWebsitesPage />;
}
export const metadata: Metadata = {
title: 'Websites',
};

View file

@ -0,0 +1,32 @@
import { useMessages, useModified, useNavigation } from '@/components/hooks';
import { Button, Icon, Modal, Dialog, DialogTrigger, Text, useToast } from '@umami/react-zen';
import { Plus } from '@/components/icons';
import { BoardAddForm } from './BoardAddForm';
export function BoardAddButton() {
const { formatMessage, labels, messages } = useMessages();
const { toast } = useToast();
const { touch } = useModified();
const { teamId } = useNavigation();
const handleSave = async () => {
toast(formatMessage(messages.saved));
touch('boards');
};
return (
<DialogTrigger>
<Button data-test="button-website-add" variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addBoard)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addBoard)} style={{ width: 400 }}>
{({ close }) => <BoardAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -0,0 +1,60 @@
import { Form, FormField, FormSubmitButton, Row, TextField, Button } from '@umami/react-zen';
import { useUpdateQuery, useMessages } from '@/components/hooks';
import { DOMAIN_REGEX } from '@/lib/constants';
export function BoardAddForm({
teamId,
onSave,
onClose,
}: {
teamId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages } = useMessages();
const { mutateAsync, error, isPending } = useUpdateQuery('/websites', { teamId });
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
onSave?.();
onClose?.();
},
});
};
return (
<Form onSubmit={handleSubmit} error={error?.message}>
<FormField
label={formatMessage(labels.name)}
data-test="input-name"
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" />
</FormField>
<FormField
label={formatMessage(labels.domain)}
data-test="input-domain"
name="domain"
rules={{
required: formatMessage(labels.required),
pattern: { value: DOMAIN_REGEX, message: formatMessage(messages.invalidDomain) },
}}
>
<TextField autoComplete="off" />
</FormField>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton data-test="button-submit" isDisabled={false}>
{formatMessage(labels.save)}
</FormSubmitButton>
</Row>
</Form>
);
}

View file

@ -0,0 +1,17 @@
'use client';
import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { PageBody } from '@/components/common/PageBody';
import { BoardAddButton } from './BoardAddButton';
export function BoardsPage() {
return (
<PageBody>
<Column margin="2">
<PageHeader title="My Boards">
<BoardAddButton />
</PageHeader>
</Column>
</PageBody>
);
}

View file

@ -0,0 +1,10 @@
import { Column, Heading } from '@umami/react-zen';
export function Board({ boardId }: { boardId: string }) {
return (
<Column>
<Heading>Board title</Heading>
<div>{boardId}</div>
</Column>
);
}

View file

@ -0,0 +1,12 @@
import { Metadata } from 'next';
import { Board } from './Board';
export default async function ({ params }: { params: Promise<{ boardId: string }> }) {
const { boardId } = await params;
return <Board boardId={boardId} />;
}
export const metadata: Metadata = {
title: 'Board',
};

View file

@ -1,10 +1,10 @@
import ReportsPage from './ReportsPage';
import { Metadata } from 'next';
import { BoardsPage } from './BoardsPage';
export default function () {
return <ReportsPage />;
return <BoardsPage />;
}
export const metadata: Metadata = {
title: 'Reports',
title: 'Boards',
};

View file

@ -1,33 +0,0 @@
.container {
display: grid;
gap: 30px;
padding-bottom: 40px;
}
.actions {
border: 1px solid var(--base400);
border-radius: 5px;
padding: 0 20px 20px 20px;
display: grid;
gap: 40px;
grid-template-columns: repeat(3, minmax(300px, 1fr));
box-shadow: 0 0 0 10px var(--base100);
}
.header {
font-size: 16px;
font-weight: 700;
margin: 20px 0;
}
.group {
display: flex;
flex-direction: column;
gap: 10px;
}
.wrapped {
border: 1px solid var(--blue900);
border-radius: 4px;
padding: 8px 16px;
}

View file

@ -1,26 +1,16 @@
'use client';
import { Button } from 'react-basics';
import { Button, Grid, Column, Heading } from '@umami/react-zen';
import Link from 'next/link';
import Script from 'next/script';
import WebsiteSelect from '@/components/input/WebsiteSelect';
import Page from '@/components/layout/Page';
import PageHeader from '@/components/layout/PageHeader';
import EventsChart from '@/components/metrics/EventsChart';
import WebsiteChart from '../websites/[websiteId]/WebsiteChart';
import { useApi, useNavigation } from '@/components/hooks';
import styles from './TestConsole.module.css';
import { Panel } from '@/components/common/Panel';
import { PageBody } from '@/components/common/PageBody';
import { EventsChart } from '@/components/metrics/EventsChart';
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
import { useWebsiteQuery } from '@/components/hooks';
import { PageHeader } from '@/components/common/PageHeader';
export function TestConsole({ websiteId }: { websiteId?: string }) {
const { get, useQuery } = useApi();
const { data, isLoading, error } = useQuery({
queryKey: ['websites:me'],
queryFn: () => get('/me/websites'),
});
const { router } = useNavigation();
function handleChange(value: string) {
router.push(`/console/${value}`);
}
export function TestConsolePage({ websiteId }: { websiteId: string }) {
const { data } = useWebsiteQuery(websiteId);
function handleRunScript() {
window['umami'].track(props => ({
@ -114,29 +104,27 @@ export function TestConsole({ websiteId }: { websiteId?: string }) {
return null;
}
const website = data?.data.find(({ id }) => websiteId === id);
return (
<Page isLoading={isLoading} error={error}>
<PageBody>
<PageHeader title="Test console">
<WebsiteSelect websiteId={website?.id} onSelect={handleChange} />
<Column>{data.name}</Column>
</PageHeader>
{website && (
<div className={styles.container}>
<Script
async
data-website-id={websiteId}
src={`${process.env.basePath || ''}/script.js`}
data-cache="true"
/>
<div className={styles.actions}>
<div className={styles.group}>
<div className={styles.header}>Page links</div>
<Column gap="6" paddingY="6">
<Script
async
data-website-id={websiteId}
src={`${process.env.basePath || ''}/script.js`}
data-cache="true"
/>
<Panel>
<Grid columns="1fr 1fr 1fr" gap>
<Column gap>
<Heading>Page links</Heading>
<div>
<Link href={`/console/${websiteId}/page/1/?q=abc`}>page one</Link>
<Link href={`/console/${websiteId}?page=1`}>page one</Link>
</div>
<div>
<Link href={`/console/${websiteId}/page/2/?q=123 `}>page two</Link>
<Link href={`/console/${websiteId}?page=2 `}>page two</Link>
</div>
<div>
<a href="https://www.google.com" data-umami-event="external-link-direct">
@ -153,9 +141,9 @@ export function TestConsole({ websiteId }: { websiteId?: string }) {
external link (tab)
</a>
</div>
</div>
<div className={styles.group}>
<div className={styles.header}>Click events</div>
</Column>
<Column gap>
<Heading>Click events</Heading>
<Button id="send-event-button" data-umami-event="button-click" variant="primary">
Send event
</Button>
@ -184,21 +172,17 @@ export function TestConsole({ websiteId }: { websiteId?: string }) {
data-umami-event-id="123"
variant="primary"
>
<div className={styles.wrapped}>Button with div</div>
<div>Button with div</div>
</Button>
<div data-umami-event="div-click" className={styles.wrapped}>
DIV with attribute
</div>
<div data-umami-event="div-click-one" className={styles.wrapped}>
<div data-umami-event="div-click-two" className={styles.wrapped}>
<div data-umami-event="div-click-three" className={styles.wrapped}>
Nested DIV
</div>
<div data-umami-event="div-click">DIV with attribute</div>
<div data-umami-event="div-click-one">
<div data-umami-event="div-click-two">
<div data-umami-event="div-click-three">Nested DIV</div>
</div>
</div>
</div>
<div className={styles.group}>
<div className={styles.header}>Javascript events</div>
</Column>
<Column gap>
<Heading>Javascript events</Heading>
<Button id="manual-button" variant="primary" onClick={handleRunScript}>
Run script
</Button>
@ -208,14 +192,16 @@ export function TestConsole({ websiteId }: { websiteId?: string }) {
<Button id="manual-button" variant="primary" onClick={handleRunRevenue}>
Revenue script
</Button>
</div>
</div>
<WebsiteChart websiteId={website.id} />
<EventsChart websiteId={website.id} />
</div>
)}
</Page>
</Column>
</Grid>
</Panel>
<Heading>Pageviews</Heading>
<WebsiteChart websiteId={websiteId} />
<Heading>Events</Heading>
<Panel>
<EventsChart websiteId={websiteId} />
</Panel>
</Column>
</PageBody>
);
}
export default TestConsole;

View file

@ -1,5 +1,5 @@
import { Metadata } from 'next';
import TestConsole from '../TestConsole';
import { TestConsolePage } from './TestConsolePage';
async function getEnabled() {
return !!process.env.ENABLE_TEST_CONSOLE;
@ -14,7 +14,7 @@ export default async function ({ params }: { params: Promise<{ websiteId: string
return null;
}
return <TestConsole websiteId={websiteId?.[0]} />;
return <TestConsolePage websiteId={websiteId} />;
}
export const metadata: Metadata = {

View file

@ -1,57 +0,0 @@
.buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 20px;
border-radius: 5px;
border: 1px solid var(--base400);
background: var(--base50);
margin-bottom: 10px;
}
.text {
position: relative;
}
.name {
font-weight: 600;
font-size: 16px;
}
.domain {
font-size: 14px;
color: var(--base700);
}
.dragActive {
cursor: grab;
}
.dragActive:active {
cursor: grabbing;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
gap: 20px;
}
.search {
max-width: 360px;
}
.active {
border-color: var(--base600);
box-shadow: 4px 4px 4px var(--base100);
}

View file

@ -1,160 +0,0 @@
import { useState, useMemo, useEffect } from 'react';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import classNames from 'classnames';
import { Button, Loading, Toggle, SearchField } from 'react-basics';
import { firstBy } from 'thenby';
import useDashboard, { saveDashboard } from '@/store/dashboard';
import { useMessages, useWebsites } from '@/components/hooks';
import styles from './DashboardEdit.module.css';
const DRAG_ID = 'dashboard-website-ordering';
export function DashboardEdit({ teamId }: { teamId: string }) {
const settings = useDashboard();
const { websiteOrder, websiteActive, isEdited } = settings;
const { formatMessage, labels } = useMessages();
const [order, setOrder] = useState(websiteOrder || []);
const [active, setActive] = useState(websiteActive || []);
const [edited, setEdited] = useState(isEdited);
const [websites, setWebsites] = useState([]);
const [search, setSearch] = useState('');
const {
result,
query: { isLoading },
setParams,
} = useWebsites({ teamId });
useEffect(() => {
if (result?.data) {
setWebsites(prevWebsites => {
const newWebsites = [...prevWebsites, ...result.data];
if (newWebsites.length < result.count) {
setParams(prevParams => ({ ...prevParams, page: prevParams.page + 1 }));
}
return newWebsites;
});
}
}, [result]);
const ordered = useMemo(() => {
if (websites) {
return websites
.map((website: { id: any; name: string; domain: string }) => ({
...website,
order: order.indexOf(website.id),
}))
.sort(firstBy('order'));
}
return [];
}, [websites, order]);
function handleWebsiteDrag({ destination, source }) {
if (!destination || destination.index === source.index) return;
const orderedWebsites = [...ordered];
const [removed] = orderedWebsites.splice(source.index, 1);
orderedWebsites.splice(destination.index, 0, removed);
setOrder(orderedWebsites.map(website => website?.id || 0));
setEdited(true);
}
function handleActiveWebsites(id: string) {
setActive(prevActive =>
prevActive.includes(id) ? prevActive.filter(a => a !== id) : [...prevActive, id],
);
setEdited(true);
}
function handleSave() {
saveDashboard({
editing: false,
isEdited: edited,
websiteOrder: order,
websiteActive: active,
});
}
function handleCancel() {
saveDashboard({ editing: false, websiteOrder, websiteActive, isEdited });
}
function handleReset() {
setOrder([]);
setActive([]);
setEdited(false);
}
if (isLoading) {
return <Loading />;
}
return (
<>
<div className={styles.header}>
<SearchField className={styles.search} value={search} onSearch={setSearch} />
<div className={styles.buttons}>
<Button onClick={handleSave} variant="primary" size="sm">
{formatMessage(labels.save)}
</Button>
<Button onClick={handleCancel} size="sm">
{formatMessage(labels.cancel)}
</Button>
<Button onClick={handleReset} size="sm">
{formatMessage(labels.reset)}
</Button>
</div>
</div>
<div className={styles.dragActive}>
<DragDropContext onDragEnd={handleWebsiteDrag}>
<Droppable droppableId={DRAG_ID}>
{(provided, snapshot) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
style={{ marginBottom: snapshot.isDraggingOver ? 260 : null }}
>
{ordered.map(({ id, name, domain }, index) => {
if (
search &&
!`${name.toLowerCase()}${domain.toLowerCase()}`.includes(search.toLowerCase())
) {
return null;
}
return (
<Draggable key={id} draggableId={`${DRAG_ID}-${id}`} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
className={classNames(styles.item, {
[styles.active]: snapshot.isDragging,
})}
{...provided.draggableProps}
{...provided.dragHandleProps}
>
<div className={styles.text}>
<div className={styles.name}>{name}</div>
<div className={styles.domain}>{domain}</div>
</div>
<Toggle
checked={active.includes(id)}
onChange={() => handleActiveWebsites(id)}
/>
</div>
)}
</Draggable>
);
})}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</div>
</>
);
}
export default DashboardEdit;

View file

@ -1,33 +1,11 @@
'use client';
import { Icon, Icons, Loading, Text } from 'react-basics';
import PageHeader from '@/components/layout/PageHeader';
import Pager from '@/components/common/Pager';
import WebsiteChartList from '../websites/[websiteId]/WebsiteChartList';
import DashboardSettingsButton from '@/app/(main)/dashboard/DashboardSettingsButton';
import DashboardEdit from '@/app/(main)/dashboard/DashboardEdit';
import EmptyPlaceholder from '@/components/common/EmptyPlaceholder';
import { useMessages, useLocale, useTeamUrl, useWebsites } from '@/components/hooks';
import useDashboard from '@/store/dashboard';
import LinkButton from '@/components/common/LinkButton';
import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { useMessages } from '@/components/hooks';
import { PageBody } from '@/components/common/PageBody';
export function DashboardPage() {
const { formatMessage, labels, messages } = useMessages();
const { teamId, renderTeamUrl } = useTeamUrl();
const { showCharts, editing, isEdited } = useDashboard();
const { dir } = useLocale();
const pageSize = isEdited ? 200 : 10;
const { result, query, params, setParams } = useWebsites({ teamId }, { pageSize });
const { page } = params;
const hasData = !!result?.data?.length;
const handlePageChange = (page: number) => {
setParams({ ...params, page });
};
if (query.isLoading) {
return <Loading />;
}
const { formatMessage, labels } = useMessages();
return (
<section style={{ marginBottom: 60 }}>
@ -67,5 +45,3 @@ export function DashboardPage() {
</section>
);
}
export default DashboardPage;

View file

@ -1,5 +0,0 @@
.buttonGroup {
display: flex;
place-items: center;
gap: 10px;
}

View file

@ -1,36 +0,0 @@
import { TooltipPopup, Icon, Text, Flexbox, Button } from 'react-basics';
import Icons from '@/components/icons';
import { saveDashboard } from '@/store/dashboard';
import { useMessages } from '@/components/hooks';
export function DashboardSettingsButton() {
const { formatMessage, labels } = useMessages();
const handleToggleCharts = () => {
saveDashboard(state => ({ showCharts: !state.showCharts }));
};
const handleEdit = () => {
saveDashboard({ editing: true });
};
return (
<Flexbox gap={10}>
<TooltipPopup label={formatMessage(labels.toggleCharts)} position="bottom">
<Button onClick={handleToggleCharts}>
<Icon>
<Icons.BarChart />
</Icon>
</Button>
</TooltipPopup>
<Button onClick={handleEdit}>
<Icon>
<Icons.Edit />
</Icon>
<Text>{formatMessage(labels.edit)}</Text>
</Button>
</Flexbox>
);
}
export default DashboardSettingsButton;

View file

@ -1,7 +1,7 @@
import DashboardPage from './DashboardPage';
import { Metadata } from 'next';
import { DashboardPage } from './DashboardPage';
export default function () {
export default async function () {
return <DashboardPage />;
}

View file

@ -1,22 +0,0 @@
.layout {
display: grid;
grid-template-rows: max-content 1fr;
grid-template-columns: 1fr;
overflow: hidden;
}
.nav {
height: 60px;
width: 100vw;
grid-column: 1;
grid-row: 1 / 2;
}
.body {
grid-column: 1;
grid-row: 2 / 3;
min-height: 0;
height: calc(100vh - 60px);
height: calc(100dvh - 60px);
overflow-y: auto;
}

View file

@ -1,21 +1,12 @@
import { Suspense } from 'react';
import { Metadata } from 'next';
import App from './App';
import NavBar from './NavBar';
import Page from '@/components/layout/Page';
import styles from './layout.module.css';
import { App } from './App';
export default async function ({ children }) {
export default function ({ children }) {
return (
<App>
<main className={styles.layout}>
<nav className={styles.nav}>
<NavBar />
</nav>
<section className={styles.body}>
<Page>{children}</Page>
</section>
</main>
</App>
<Suspense>
<App>{children}</App>
</Suspense>
);
}

View file

@ -0,0 +1,24 @@
import { useMessages } from '@/components/hooks';
import { Button, Icon, Modal, Dialog, DialogTrigger, Text } from '@umami/react-zen';
import { Plus } from '@/components/icons';
import { LinkEditForm } from './LinkEditForm';
export function LinkAddButton({ teamId }: { teamId?: string }) {
const { formatMessage, labels } = useMessages();
return (
<DialogTrigger>
<Button data-test="button-website-add" variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addLink)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addLink)} style={{ width: 600 }}>
{({ close }) => <LinkEditForm teamId={teamId} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -0,0 +1,55 @@
import { Dialog } from '@umami/react-zen';
import { ActionButton } from '@/components/input/ActionButton';
import { Trash } from '@/components/icons';
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { messages } from '@/components/messages';
import { useDeleteQuery, useMessages } from '@/components/hooks';
export function LinkDeleteButton({
linkId,
name,
onSave,
}: {
linkId: string;
websiteId: string;
name: string;
onSave?: () => void;
}) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/links/${linkId}`);
const handleConfirm = async (close: () => void) => {
await mutateAsync(null, {
onSuccess: () => {
touch('links');
onSave?.();
close();
},
});
};
return (
<ActionButton title={formatMessage(labels.delete)} icon={<Trash />}>
<Dialog title={formatMessage(labels.confirm)} style={{ width: 400 }}>
{({ close }) => (
<ConfirmationForm
message={
<FormattedMessage
{...messages.confirmRemove}
values={{
target: <b>{name}</b>,
}}
/>
}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.delete)}
buttonVariant="danger"
/>
)}
</Dialog>
</ActionButton>
);
}

View file

@ -0,0 +1,19 @@
import { ActionButton } from '@/components/input/ActionButton';
import { Edit } from '@/components/icons';
import { Dialog } from '@umami/react-zen';
import { LinkEditForm } from './LinkEditForm';
import { useMessages } from '@/components/hooks';
export function LinkEditButton({ linkId }: { linkId: string }) {
const { formatMessage, labels } = useMessages();
return (
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
<Dialog title={formatMessage(labels.link)} style={{ width: 800, minHeight: 300 }}>
{({ close }) => {
return <LinkEditForm linkId={linkId} onClose={close} />;
}}
</Dialog>
</ActionButton>
);
}

View file

@ -0,0 +1,149 @@
import { useState, useEffect } from 'react';
import {
Form,
FormField,
FormSubmitButton,
Row,
TextField,
Button,
Label,
Column,
Icon,
Loading,
} from '@umami/react-zen';
import { useConfig, useLinkQuery } from '@/components/hooks';
import { useMessages } from '@/components/hooks';
import { RefreshCw } from '@/components/icons';
import { getRandomChars } from '@/lib/generate';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { LINKS_URL } from '@/lib/constants';
import { isValidUrl } from '@/lib/url';
const generateId = () => getRandomChars(9);
export function LinkEditForm({
linkId,
teamId,
onSave,
onClose,
}: {
linkId?: string;
teamId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
linkId ? `/links/${linkId}` : '/links',
{
id: linkId,
teamId,
},
);
const { linksUrl } = useConfig();
const hostUrl = linksUrl || LINKS_URL;
const { data, isLoading } = useLinkQuery(linkId);
const [slug, setSlug] = useState(generateId());
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('links');
onSave?.();
onClose?.();
},
});
};
const handleSlug = () => {
const slug = generateId();
setSlug(slug);
return slug;
};
const checkUrl = (url: string) => {
if (!isValidUrl(url)) {
return formatMessage(labels.invalidUrl);
}
return true;
};
useEffect(() => {
if (data) {
setSlug(data.slug);
}
}, [data]);
if (linkId && isLoading) {
return <Loading placement="absolute" />;
}
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}>
{({ setValue }) => {
return (
<>
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" autoFocus />
</FormField>
<FormField
label={formatMessage(labels.destinationUrl)}
name="url"
rules={{ required: formatMessage(labels.required), validate: checkUrl }}
>
<TextField placeholder="https://example.com" autoComplete="off" />
</FormField>
<FormField
name="slug"
rules={{
required: formatMessage(labels.required),
}}
style={{ display: 'none' }}
>
<input type="hidden" />
</FormField>
<Column>
<Label>{formatMessage(labels.link)}</Label>
<Row alignItems="center" gap>
<TextField
value={`${hostUrl}/${slug}`}
autoComplete="off"
isReadOnly
allowCopy
style={{ width: '100%' }}
/>
<Button
variant="quiet"
onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}
>
<Icon>
<RefreshCw />
</Icon>
</Button>
</Row>
</Column>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</>
);
}}
</Form>
);
}

View file

@ -0,0 +1,21 @@
'use client';
import { createContext, ReactNode } from 'react';
import { Loading } from '@umami/react-zen';
import { Link } from '@/generated/prisma/client';
import { useLinkQuery } from '@/components/hooks/queries/useLinkQuery';
export const LinkContext = createContext<Link>(null);
export function LinkProvider({ linkId, children }: { linkId?: string; children: ReactNode }) {
const { data: link, isLoading, isFetching } = useLinkQuery(linkId);
if (isFetching && isLoading) {
return <Loading placement="absolute" />;
}
if (!link) {
return null;
}
return <LinkContext.Provider value={link}>{children}</LinkContext.Provider>;
}

View file

@ -0,0 +1,14 @@
import { useLinksQuery, useNavigation } from '@/components/hooks';
import { LinksTable } from './LinksTable';
import { DataGrid } from '@/components/common/DataGrid';
export function LinksDataTable() {
const { teamId } = useNavigation();
const query = useLinksQuery({ teamId });
return (
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
{({ data }) => <LinksTable data={data} />}
</DataGrid>
);
}

View file

@ -0,0 +1,26 @@
'use client';
import { PageBody } from '@/components/common/PageBody';
import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { LinkAddButton } from './LinkAddButton';
import { useMessages, useNavigation } from '@/components/hooks';
import { LinksDataTable } from '@/app/(main)/links/LinksDataTable';
import { Panel } from '@/components/common/Panel';
export function LinksPage() {
const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation();
return (
<PageBody>
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.links)}>
<LinkAddButton teamId={teamId} />
</PageHeader>
<Panel>
<LinksDataTable />
</Panel>
</Column>
</PageBody>
);
}

View file

@ -0,0 +1,52 @@
import Link from 'next/link';
import { DataTable, DataColumn, Row } from '@umami/react-zen';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { Empty } from '@/components/common/Empty';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { LinkEditButton } from './LinkEditButton';
import { LinkDeleteButton } from './LinkDeleteButton';
export function LinksTable({ data = [] }) {
const { formatMessage, labels } = useMessages();
const { websiteId, renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('link');
if (data.length === 0) {
return <Empty />;
}
return (
<DataTable data={data}>
<DataColumn id="name" label={formatMessage(labels.name)}>
{({ id, name }: any) => {
return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;
}}
</DataColumn>
<DataColumn id="slug" label={formatMessage(labels.link)}>
{({ slug }: any) => {
const url = getSlugUrl(slug);
return <ExternalLink href={url}>{url}</ExternalLink>;
}}
</DataColumn>
<DataColumn id="url" label={formatMessage(labels.destinationUrl)}>
{({ url }: any) => {
return <ExternalLink href={url}>{url}</ExternalLink>;
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)} width="200px">
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
{({ id, name }: any) => {
return (
<Row>
<LinkEditButton linkId={id} />
<LinkDeleteButton linkId={id} websiteId={websiteId} name={name} />
</Row>
);
}}
</DataColumn>
</DataTable>
);
}

View file

@ -0,0 +1,32 @@
import { Column, Row } from '@umami/react-zen';
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { FilterBar } from '@/components/input/FilterBar';
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
import { ExportButton } from '@/components/input/ExportButton';
export function LinkControls({
linkId: websiteId,
allowFilter = true,
allowDateFilter = true,
allowMonthFilter,
allowDownload = false,
}: {
linkId: string;
allowFilter?: boolean;
allowDateFilter?: boolean;
allowMonthFilter?: boolean;
allowDownload?: boolean;
}) {
return (
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap="3">
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
{allowDownload && <ExportButton websiteId={websiteId} />}
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
</Row>
{allowFilter && <FilterBar websiteId={websiteId} />}
</Column>
);
}

View file

@ -0,0 +1,22 @@
import { useLink, useMessages, useSlug } from '@/components/hooks';
import { PageHeader } from '@/components/common/PageHeader';
import { Icon, Text } from '@umami/react-zen';
import { ExternalLink, Link } from '@/components/icons';
import { LinkButton } from '@/components/common/LinkButton';
export function LinkHeader() {
const { formatMessage, labels } = useMessages();
const { getSlugUrl } = useSlug('link');
const link = useLink();
return (
<PageHeader title={link.name} description={link.url} icon={<Link />} marginBottom="3">
<LinkButton href={getSlugUrl(link.slug)} target="_blank">
<Icon>
<ExternalLink />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
</LinkButton>
</PageHeader>
);
}

View file

@ -0,0 +1,71 @@
import { useDateRange, useMessages } from '@/components/hooks';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { formatLongNumber } from '@/lib/format';
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
import { LoadingPanel } from '@/components/common/LoadingPanel';
export function LinkMetricsBar({
linkId,
}: {
linkId: string;
showChange?: boolean;
compareMode?: boolean;
}) {
const { dateRange } = useDateRange(linkId);
const { formatMessage, labels } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(linkId);
const isAllTime = dateRange.value === 'all';
const { pageviews, visitors, visits, comparison } = data || {};
const metrics = data
? [
{
value: visitors,
label: formatMessage(labels.visitors),
change: visitors - comparison.visitors,
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
change: visits - comparison.visits,
formatValue: formatLongNumber,
},
{
value: pageviews,
label: formatMessage(labels.views),
change: pageviews - comparison.pageviews,
formatValue: formatLongNumber,
},
]
: null;
return (
<LoadingPanel
data={metrics}
isLoading={isLoading}
isFetching={isFetching}
error={error}
minHeight="136px"
>
<MetricsBar>
{metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
return (
<MetricCard
key={label}
value={value}
previousValue={prev}
label={label}
change={change}
formatValue={formatValue}
reverseColors={reverseColors}
showChange={!isAllTime}
/>
);
})}
</MetricsBar>
</LoadingPanel>
);
}

View file

@ -0,0 +1,30 @@
'use client';
import { PageBody } from '@/components/common/PageBody';
import { LinkProvider } from '@/app/(main)/links/LinkProvider';
import { LinkHeader } from '@/app/(main)/links/[linkId]/LinkHeader';
import { Panel } from '@/components/common/Panel';
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar';
import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls';
import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels';
import { Column, Grid } from '@umami/react-zen';
export function LinkPage({ linkId }: { linkId: string }) {
return (
<LinkProvider linkId={linkId}>
<Grid width="100%" height="100%">
<Column margin="2">
<PageBody gap>
<LinkHeader />
<LinkControls linkId={linkId} />
<LinkMetricsBar linkId={linkId} showChange={true} />
<Panel>
<WebsiteChart websiteId={linkId} />
</Panel>
<LinkPanels linkId={linkId} />
</PageBody>
</Column>
</Grid>
</LinkProvider>
);
}

View file

@ -0,0 +1,83 @@
import { Grid, Tabs, Tab, TabList, TabPanel, Heading } from '@umami/react-zen';
import { GridRow } from '@/components/common/GridRow';
import { Panel } from '@/components/common/Panel';
import { WorldMap } from '@/components/metrics/WorldMap';
import { MetricsTable } from '@/components/metrics/MetricsTable';
import { useMessages } from '@/components/hooks';
export function LinkPanels({ linkId }: { linkId: string }) {
const { formatMessage, labels } = useMessages();
const tableProps = {
websiteId: linkId,
limit: 10,
allowDownload: false,
showMore: true,
metric: formatMessage(labels.visitors),
};
const rowProps = { minHeight: 570 };
return (
<Grid gap="3">
<GridRow layout="two" {...rowProps}>
<Panel>
<Heading size="2">{formatMessage(labels.sources)}</Heading>
<Tabs>
<TabList>
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
</TabList>
<TabPanel id="referrer">
<MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
</TabPanel>
<TabPanel id="channel">
<MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
<Panel>
<Heading size="2">{formatMessage(labels.environment)}</Heading>
<Tabs>
<TabList>
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
<Tab id="os">{formatMessage(labels.os)}</Tab>
<Tab id="device">{formatMessage(labels.devices)}</Tab>
</TabList>
<TabPanel id="browser">
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
</TabPanel>
<TabPanel id="os">
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
</TabPanel>
<TabPanel id="device">
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
</GridRow>
<GridRow layout="two" {...rowProps}>
<Panel noPadding>
<WorldMap websiteId={linkId} />
</Panel>
<Panel>
<Heading size="2">{formatMessage(labels.location)}</Heading>
<Tabs>
<TabList>
<Tab id="country">{formatMessage(labels.countries)}</Tab>
<Tab id="region">{formatMessage(labels.regions)}</Tab>
<Tab id="city">{formatMessage(labels.cities)}</Tab>
</TabList>
<TabPanel id="country">
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
</TabPanel>
<TabPanel id="region">
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
</TabPanel>
<TabPanel id="city">
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
</GridRow>
</Grid>
);
}

View file

@ -0,0 +1,12 @@
import { LinkPage } from './LinkPage';
import { Metadata } from 'next';
export default async function ({ params }: { params: Promise<{ linkId: string }> }) {
const { linkId } = await params;
return <LinkPage linkId={linkId} />;
}
export const metadata: Metadata = {
title: 'Link',
};

View file

@ -1,10 +1,10 @@
import { LinksPage } from './LinksPage';
import { Metadata } from 'next';
import UTMReportPage from './UTMReportPage';
export default function () {
return <UTMReportPage />;
return <LinksPage />;
}
export const metadata: Metadata = {
title: 'Goals Report',
title: 'Links',
};

View file

@ -0,0 +1,24 @@
import { useMessages } from '@/components/hooks';
import { Button, Icon, Modal, Dialog, DialogTrigger, Text } from '@umami/react-zen';
import { Plus } from '@/components/icons';
import { PixelEditForm } from './PixelEditForm';
export function PixelAddButton({ teamId }: { teamId?: string }) {
const { formatMessage, labels } = useMessages();
return (
<DialogTrigger>
<Button data-test="button-website-add" variant="primary">
<Icon>
<Plus />
</Icon>
<Text>{formatMessage(labels.addPixel)}</Text>
</Button>
<Modal>
<Dialog title={formatMessage(labels.addPixel)} style={{ width: 600 }}>
{({ close }) => <PixelEditForm teamId={teamId} onClose={close} />}
</Dialog>
</Modal>
</DialogTrigger>
);
}

View file

@ -0,0 +1,54 @@
import { Dialog } from '@umami/react-zen';
import { ActionButton } from '@/components/input/ActionButton';
import { Trash } from '@/components/icons';
import { ConfirmationForm } from '@/components/common/ConfirmationForm';
import { messages } from '@/components/messages';
import { useDeleteQuery, useMessages, useModified } from '@/components/hooks';
export function PixelDeleteButton({
pixelId,
name,
onSave,
}: {
pixelId: string;
name: string;
onSave?: () => void;
}) {
const { formatMessage, labels, getErrorMessage, FormattedMessage } = useMessages();
const { mutateAsync, isPending, error } = useDeleteQuery(`/pixels/${pixelId}`);
const { touch } = useModified();
const handleConfirm = async (close: () => void) => {
await mutateAsync(null, {
onSuccess: () => {
touch('pixels');
onSave?.();
close();
},
});
};
return (
<ActionButton title={formatMessage(labels.delete)} icon={<Trash />}>
<Dialog title={formatMessage(labels.confirm)} style={{ width: 400 }}>
{({ close }) => (
<ConfirmationForm
message={
<FormattedMessage
{...messages.confirmRemove}
values={{
target: <b>{name}</b>,
}}
/>
}
isLoading={isPending}
error={getErrorMessage(error)}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.delete)}
buttonVariant="danger"
/>
)}
</Dialog>
</ActionButton>
);
}

View file

@ -0,0 +1,19 @@
import { ActionButton } from '@/components/input/ActionButton';
import { Edit } from '@/components/icons';
import { Dialog } from '@umami/react-zen';
import { PixelEditForm } from './PixelEditForm';
import { useMessages } from '@/components/hooks';
export function PixelEditButton({ pixelId }: { pixelId: string }) {
const { formatMessage, labels } = useMessages();
return (
<ActionButton title={formatMessage(labels.edit)} icon={<Edit />}>
<Dialog title={formatMessage(labels.pixel)} style={{ width: 600, minHeight: 300 }}>
{({ close }) => {
return <PixelEditForm pixelId={pixelId} onClose={close} />;
}}
</Dialog>
</ActionButton>
);
}

View file

@ -0,0 +1,130 @@
import {
Form,
FormField,
FormSubmitButton,
Row,
TextField,
Button,
Label,
Column,
Icon,
Loading,
} from '@umami/react-zen';
import { useConfig, usePixelQuery } from '@/components/hooks';
import { useMessages } from '@/components/hooks';
import { RefreshCw } from '@/components/icons';
import { getRandomChars } from '@/lib/generate';
import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery';
import { useEffect, useState } from 'react';
import { PIXELS_URL } from '@/lib/constants';
const generateId = () => getRandomChars(9);
export function PixelEditForm({
pixelId,
teamId,
onSave,
onClose,
}: {
pixelId?: string;
teamId?: string;
onSave?: () => void;
onClose?: () => void;
}) {
const { formatMessage, labels, messages, getErrorMessage } = useMessages();
const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(
pixelId ? `/pixels/${pixelId}` : '/pixels',
{
id: pixelId,
teamId,
},
);
const { pixelsUrl } = useConfig();
const hostUrl = pixelsUrl || PIXELS_URL;
const { data, isLoading } = usePixelQuery(pixelId);
const [slug, setSlug] = useState(generateId());
const handleSubmit = async (data: any) => {
await mutateAsync(data, {
onSuccess: async () => {
toast(formatMessage(messages.saved));
touch('pixels');
onSave?.();
onClose?.();
},
});
};
const handleSlug = () => {
const slug = generateId();
setSlug(slug);
return slug;
};
useEffect(() => {
if (data) {
setSlug(data.slug);
}
}, [data]);
if (pixelId && isLoading) {
return <Loading placement="absolute" />;
}
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ slug, ...data }}>
{({ setValue }) => {
return (
<>
<FormField
label={formatMessage(labels.name)}
name="name"
rules={{ required: formatMessage(labels.required) }}
>
<TextField autoComplete="off" />
</FormField>
<FormField
name="slug"
rules={{
required: formatMessage(labels.required),
}}
style={{ display: 'none' }}
>
<input type="hidden" />
</FormField>
<Column>
<Label>{formatMessage(labels.link)}</Label>
<Row alignItems="center" gap>
<TextField
value={`${hostUrl}/${slug}`}
autoComplete="off"
isReadOnly
allowCopy
style={{ width: '100%' }}
/>
<Button onPress={() => setValue('slug', handleSlug(), { shouldDirty: true })}>
<Icon>
<RefreshCw />
</Icon>
</Button>
</Row>
</Column>
<Row justifyContent="flex-end" paddingTop="3" gap="3">
{onClose && (
<Button isDisabled={isPending} onPress={onClose}>
{formatMessage(labels.cancel)}
</Button>
)}
<FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton>
</Row>
</>
);
}}
</Form>
);
}

View file

@ -0,0 +1,21 @@
'use client';
import { createContext, ReactNode } from 'react';
import { Loading } from '@umami/react-zen';
import { Pixel } from '@/generated/prisma/client';
import { usePixelQuery } from '@/components/hooks/queries/usePixelQuery';
export const PixelContext = createContext<Pixel>(null);
export function PixelProvider({ pixelId, children }: { pixelId?: string; children: ReactNode }) {
const { data: pixel, isLoading, isFetching } = usePixelQuery(pixelId);
if (isFetching && isLoading) {
return <Loading placement="absolute" />;
}
if (!pixel) {
return null;
}
return <PixelContext.Provider value={pixel}>{children}</PixelContext.Provider>;
}

View file

@ -0,0 +1,14 @@
import { usePixelsQuery, useNavigation } from '@/components/hooks';
import { PixelsTable } from './PixelsTable';
import { DataGrid } from '@/components/common/DataGrid';
export function PixelsDataTable() {
const { teamId } = useNavigation();
const query = usePixelsQuery({ teamId });
return (
<DataGrid query={query} allowSearch={true} autoFocus={false} allowPaging={true}>
{({ data }) => <PixelsTable data={data} />}
</DataGrid>
);
}

View file

@ -0,0 +1,26 @@
'use client';
import { PageBody } from '@/components/common/PageBody';
import { Column } from '@umami/react-zen';
import { PageHeader } from '@/components/common/PageHeader';
import { PixelAddButton } from './PixelAddButton';
import { useMessages, useNavigation } from '@/components/hooks';
import { PixelsDataTable } from './PixelsDataTable';
import { Panel } from '@/components/common/Panel';
export function PixelsPage() {
const { formatMessage, labels } = useMessages();
const { teamId } = useNavigation();
return (
<PageBody>
<Column gap="6" margin="2">
<PageHeader title={formatMessage(labels.pixels)}>
<PixelAddButton teamId={teamId} />
</PageHeader>
<Panel>
<PixelsDataTable />
</Panel>
</Column>
</PageBody>
);
}

View file

@ -0,0 +1,49 @@
import Link from 'next/link';
import { DataTable, DataColumn, Row } from '@umami/react-zen';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { Empty } from '@/components/common/Empty';
import { DateDistance } from '@/components/common/DateDistance';
import { PixelEditButton } from './PixelEditButton';
import { PixelDeleteButton } from './PixelDeleteButton';
import { ExternalLink } from '@/components/common/ExternalLink';
export function PixelsTable({ data = [] }) {
const { formatMessage, labels } = useMessages();
const { renderUrl } = useNavigation();
const { getSlugUrl } = useSlug('pixel');
if (data.length === 0) {
return <Empty />;
}
return (
<DataTable data={data}>
<DataColumn id="name" label={formatMessage(labels.name)}>
{({ id, name }: any) => {
return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;
}}
</DataColumn>
<DataColumn id="url" label="URL">
{({ slug }: any) => {
const url = getSlugUrl(slug);
return <ExternalLink href={url}>{url}</ExternalLink>;
}}
</DataColumn>
<DataColumn id="created" label={formatMessage(labels.created)}>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
{(row: any) => {
const { id, name } = row;
return (
<Row>
<PixelEditButton pixelId={id} />
<PixelDeleteButton pixelId={id} name={name} />
</Row>
);
}}
</DataColumn>
</DataTable>
);
}

View file

@ -0,0 +1,32 @@
import { Column, Row } from '@umami/react-zen';
import { WebsiteFilterButton } from '@/app/(main)/websites/[websiteId]/WebsiteFilterButton';
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { FilterBar } from '@/components/input/FilterBar';
import { WebsiteMonthSelect } from '@/components/input/WebsiteMonthSelect';
import { ExportButton } from '@/components/input/ExportButton';
export function PixelControls({
pixelId: websiteId,
allowFilter = true,
allowDateFilter = true,
allowMonthFilter,
allowDownload = false,
}: {
pixelId: string;
allowFilter?: boolean;
allowDateFilter?: boolean;
allowMonthFilter?: boolean;
allowDownload?: boolean;
}) {
return (
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap="3">
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
{allowDownload && <ExportButton websiteId={websiteId} />}
{allowMonthFilter && <WebsiteMonthSelect websiteId={websiteId} />}
</Row>
{allowFilter && <FilterBar websiteId={websiteId} />}
</Column>
);
}

View file

@ -0,0 +1,22 @@
import { usePixel, useMessages, useSlug } from '@/components/hooks';
import { PageHeader } from '@/components/common/PageHeader';
import { Icon, Text } from '@umami/react-zen';
import { ExternalLink, Grid2x2 } from '@/components/icons';
import { LinkButton } from '@/components/common/LinkButton';
export function PixelHeader() {
const { formatMessage, labels } = useMessages();
const { getSlugUrl } = useSlug('pixel');
const pixel = usePixel();
return (
<PageHeader title={pixel.name} icon={<Grid2x2 />} marginBottom="3">
<LinkButton href={getSlugUrl(pixel.slug)} target="_blank">
<Icon>
<ExternalLink />
</Icon>
<Text>{formatMessage(labels.view)}</Text>
</LinkButton>
</PageHeader>
);
}

View file

@ -0,0 +1,71 @@
import { useDateRange, useMessages } from '@/components/hooks';
import { MetricCard } from '@/components/metrics/MetricCard';
import { MetricsBar } from '@/components/metrics/MetricsBar';
import { formatLongNumber } from '@/lib/format';
import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery';
import { LoadingPanel } from '@/components/common/LoadingPanel';
export function PixelMetricsBar({
pixelId,
}: {
pixelId: string;
showChange?: boolean;
compareMode?: boolean;
}) {
const { dateRange } = useDateRange(pixelId);
const { formatMessage, labels } = useMessages();
const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(pixelId);
const isAllTime = dateRange.value === 'all';
const { pageviews, visitors, visits, comparison } = data || {};
const metrics = data
? [
{
value: visitors,
label: formatMessage(labels.visitors),
change: visitors - comparison.visitors,
formatValue: formatLongNumber,
},
{
value: visits,
label: formatMessage(labels.visits),
change: visits - comparison.visits,
formatValue: formatLongNumber,
},
{
value: pageviews,
label: formatMessage(labels.views),
change: pageviews - comparison.pageviews,
formatValue: formatLongNumber,
},
]
: null;
return (
<LoadingPanel
data={metrics}
isLoading={isLoading}
isFetching={isFetching}
error={error}
minHeight="136px"
>
<MetricsBar>
{metrics?.map(({ label, value, prev, change, formatValue, reverseColors }: any) => {
return (
<MetricCard
key={label}
value={value}
previousValue={prev}
label={label}
change={change}
formatValue={formatValue}
reverseColors={reverseColors}
showChange={!isAllTime}
/>
);
})}
</MetricsBar>
</LoadingPanel>
);
}

View file

@ -0,0 +1,30 @@
'use client';
import { PageBody } from '@/components/common/PageBody';
import { PixelProvider } from '@/app/(main)/pixels/PixelProvider';
import { PixelHeader } from '@/app/(main)/pixels/[pixelId]/PixelHeader';
import { Panel } from '@/components/common/Panel';
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar';
import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls';
import { PixelPanels } from '@/app/(main)/pixels/[pixelId]/PixelPanels';
import { Column, Grid } from '@umami/react-zen';
export function PixelPage({ pixelId }: { pixelId: string }) {
return (
<PixelProvider pixelId={pixelId}>
<Grid width="100%" height="100%">
<Column margin="2">
<PageBody gap>
<PixelHeader />
<PixelControls pixelId={pixelId} />
<PixelMetricsBar pixelId={pixelId} showChange={true} />
<Panel>
<WebsiteChart websiteId={pixelId} />
</Panel>
<PixelPanels pixelId={pixelId} />
</PageBody>
</Column>
</Grid>
</PixelProvider>
);
}

View file

@ -0,0 +1,83 @@
import { Grid, Tabs, Tab, TabList, TabPanel, Heading } from '@umami/react-zen';
import { GridRow } from '@/components/common/GridRow';
import { Panel } from '@/components/common/Panel';
import { WorldMap } from '@/components/metrics/WorldMap';
import { MetricsTable } from '@/components/metrics/MetricsTable';
import { useMessages } from '@/components/hooks';
export function PixelPanels({ pixelId }: { pixelId: string }) {
const { formatMessage, labels } = useMessages();
const tableProps = {
websiteId: pixelId,
limit: 10,
allowDownload: false,
showMore: true,
metric: formatMessage(labels.visitors),
};
const rowProps = { minHeight: 570 };
return (
<Grid gap="3">
<GridRow layout="two" {...rowProps}>
<Panel>
<Heading size="2">{formatMessage(labels.sources)}</Heading>
<Tabs>
<TabList>
<Tab id="referrer">{formatMessage(labels.referrers)}</Tab>
<Tab id="channel">{formatMessage(labels.channels)}</Tab>
</TabList>
<TabPanel id="referrer">
<MetricsTable type="referrer" title={formatMessage(labels.domain)} {...tableProps} />
</TabPanel>
<TabPanel id="channel">
<MetricsTable type="channel" title={formatMessage(labels.type)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
<Panel>
<Heading size="2">{formatMessage(labels.environment)}</Heading>
<Tabs>
<TabList>
<Tab id="browser">{formatMessage(labels.browsers)}</Tab>
<Tab id="os">{formatMessage(labels.os)}</Tab>
<Tab id="device">{formatMessage(labels.devices)}</Tab>
</TabList>
<TabPanel id="browser">
<MetricsTable type="browser" title={formatMessage(labels.browser)} {...tableProps} />
</TabPanel>
<TabPanel id="os">
<MetricsTable type="os" title={formatMessage(labels.os)} {...tableProps} />
</TabPanel>
<TabPanel id="device">
<MetricsTable type="device" title={formatMessage(labels.device)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
</GridRow>
<GridRow layout="two" {...rowProps}>
<Panel noPadding>
<WorldMap websiteId={pixelId} />
</Panel>
<Panel>
<Heading size="2">{formatMessage(labels.location)}</Heading>
<Tabs>
<TabList>
<Tab id="country">{formatMessage(labels.countries)}</Tab>
<Tab id="region">{formatMessage(labels.regions)}</Tab>
<Tab id="city">{formatMessage(labels.cities)}</Tab>
</TabList>
<TabPanel id="country">
<MetricsTable type="country" title={formatMessage(labels.country)} {...tableProps} />
</TabPanel>
<TabPanel id="region">
<MetricsTable type="region" title={formatMessage(labels.region)} {...tableProps} />
</TabPanel>
<TabPanel id="city">
<MetricsTable type="city" title={formatMessage(labels.city)} {...tableProps} />
</TabPanel>
</Tabs>
</Panel>
</GridRow>
</Grid>
);
}

View file

@ -0,0 +1,12 @@
import { PixelPage } from './PixelPage';
import { Metadata } from 'next';
export default async function ({ params }: { params: { pixelId: string } }) {
const { pixelId } = await params;
return <PixelPage pixelId={pixelId} />;
}
export const metadata: Metadata = {
title: 'Pixel',
};

View file

@ -0,0 +1,10 @@
import { PixelsPage } from './PixelsPage';
import { Metadata } from 'next';
export default function () {
return <PixelsPage />;
}
export const metadata: Metadata = {
title: 'Pixels',
};

View file

@ -1,3 +0,0 @@
.field {
width: 200px;
}

View file

@ -1,30 +0,0 @@
import DateFilter from '@/components/input/DateFilter';
import { Button, Flexbox } from 'react-basics';
import { useDateRange, useMessages } from '@/components/hooks';
import { DEFAULT_DATE_RANGE } from '@/lib/constants';
import { DateRange } from '@/lib/types';
import styles from './DateRangeSetting.module.css';
export function DateRangeSetting() {
const { formatMessage, labels } = useMessages();
const { dateRange, saveDateRange } = useDateRange();
const { value } = dateRange;
const handleChange = (value: string | DateRange) => saveDateRange(value);
const handleReset = () => saveDateRange(DEFAULT_DATE_RANGE);
return (
<Flexbox gap={10} width={300}>
<DateFilter
className={styles.field}
value={value}
startDate={dateRange.startDate}
endDate={dateRange.endDate}
onChange={handleChange}
/>
<Button onClick={handleReset}>{formatMessage(labels.reset)}</Button>
</Flexbox>
);
}
export default DateRangeSetting;

View file

@ -1,4 +0,0 @@
div.menu {
max-height: 300px;
width: 300px;
}

View file

@ -1,31 +0,0 @@
import { Button, Icon, Text, useToasts, ModalTrigger, Modal } from 'react-basics';
import PasswordEditForm from '@/app/(main)/profile/PasswordEditForm';
import Icons from '@/components/icons';
import { useMessages } from '@/components/hooks';
export function PasswordChangeButton() {
const { formatMessage, labels, messages } = useMessages();
const { showToast } = useToasts();
const handleSave = () => {
showToast({ message: formatMessage(messages.saved), variant: 'success' });
};
return (
<>
<ModalTrigger>
<Button>
<Icon>
<Icons.Lock />
</Icon>
<Text>{formatMessage(labels.changePassword)}</Text>
</Button>
<Modal title={formatMessage(labels.changePassword)}>
{close => <PasswordEditForm onSave={handleSave} onClose={close} />}
</Modal>
</ModalTrigger>
</>
);
}
export default PasswordChangeButton;

View file

@ -1,69 +0,0 @@
import { useRef } from 'react';
import { Form, FormRow, FormInput, FormButtons, PasswordField, Button } from 'react-basics';
import { useApi, useMessages } from '@/components/hooks';
export function PasswordEditForm({ onSave, onClose }) {
const { formatMessage, labels, messages } = useMessages();
const { post, useMutation } = useApi();
const { mutate, error, isPending } = useMutation({
mutationFn: (data: any) => post('/me/password', data),
});
const ref = useRef(null);
const handleSubmit = async (data: any) => {
mutate(data, {
onSuccess: async () => {
onSave();
onClose();
},
});
};
const samePassword = (value: string) => {
if (value !== ref?.current?.getValues('newPassword')) {
return formatMessage(messages.noMatchPassword);
}
return true;
};
return (
<Form ref={ref} onSubmit={handleSubmit} error={error}>
<FormRow label={formatMessage(labels.currentPassword)}>
<FormInput name="currentPassword" rules={{ required: 'Required' }}>
<PasswordField autoComplete="current-password" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.newPassword)}>
<FormInput
name="newPassword"
rules={{
required: 'Required',
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
}}
>
<PasswordField autoComplete="new-password" />
</FormInput>
</FormRow>
<FormRow label={formatMessage(labels.confirmPassword)}>
<FormInput
name="confirmPassword"
rules={{
required: formatMessage(labels.required),
minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: 8 }) },
validate: samePassword,
}}
>
<PasswordField autoComplete="confirm-password" />
</FormInput>
</FormRow>
<FormButtons flex>
<Button type="submit" variant="primary" disabled={isPending}>
{formatMessage(labels.save)}
</Button>
<Button onClick={onClose}>{formatMessage(labels.cancel)}</Button>
</FormButtons>
</Form>
);
}
export default PasswordEditForm;

View file

@ -1,10 +0,0 @@
import PageHeader from '@/components/layout/PageHeader';
import { useMessages } from '@/components/hooks';
export function ProfileHeader() {
const { formatMessage, labels } = useMessages();
return <PageHeader title={formatMessage(labels.profile)}></PageHeader>;
}
export default ProfileHeader;

View file

@ -1,9 +0,0 @@
.container {
margin: 0 auto;
}
@media screen and (max-width: 768px) {
.container {
margin: 0;
}
}

View file

@ -1,13 +0,0 @@
'use client';
import ProfileHeader from './ProfileHeader';
import ProfileSettings from './ProfileSettings';
import styles from './ProfilePage.module.css';
export default function () {
return (
<div className={styles.container}>
<ProfileHeader />
<ProfileSettings />
</div>
);
}

View file

@ -1,60 +0,0 @@
import { Form, FormRow } from 'react-basics';
import TimezoneSetting from '@/app/(main)/profile/TimezoneSetting';
import DateRangeSetting from '@/app/(main)/profile/DateRangeSetting';
import LanguageSetting from '@/app/(main)/profile/LanguageSetting';
import ThemeSetting from '@/app/(main)/profile/ThemeSetting';
import PasswordChangeButton from './PasswordChangeButton';
import { useLogin, useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
export function ProfileSettings() {
const { user } = useLogin();
const { formatMessage, labels } = useMessages();
const cloudMode = !!process.env.cloudMode;
if (!user) {
return null;
}
const { username, role } = user;
const renderRole = (value: string) => {
if (value === ROLES.user) {
return formatMessage(labels.user);
}
if (value === ROLES.admin) {
return formatMessage(labels.admin);
}
if (value === ROLES.viewOnly) {
return formatMessage(labels.viewOnly);
}
return formatMessage(labels.unknown);
};
return (
<Form>
<FormRow label={formatMessage(labels.username)}>{username}</FormRow>
<FormRow label={formatMessage(labels.role)}>{renderRole(role)}</FormRow>
{!cloudMode && (
<FormRow label={formatMessage(labels.password)}>
<PasswordChangeButton />
</FormRow>
)}
<FormRow label={formatMessage(labels.defaultDateRange)}>
<DateRangeSetting />
</FormRow>
<FormRow label={formatMessage(labels.language)}>
<LanguageSetting />
</FormRow>
<FormRow label={formatMessage(labels.timezone)}>
<TimezoneSetting />
</FormRow>
<FormRow label={formatMessage(labels.theme)}>
<ThemeSetting />
</FormRow>
</Form>
);
}
export default ProfileSettings;

View file

@ -1,8 +0,0 @@
.buttons {
display: flex;
gap: 10px;
}
.active {
border: 2px solid var(--primary400);
}

View file

@ -1,33 +0,0 @@
import classNames from 'classnames';
import { Button, Icon } from 'react-basics';
import { useTheme } from '@/components/hooks';
import Sun from '@/assets/sun.svg';
import Moon from '@/assets/moon.svg';
import styles from './ThemeSetting.module.css';
export function ThemeSetting() {
const { theme, saveTheme } = useTheme();
return (
<div className={styles.buttons}>
<Button
className={classNames({ [styles.active]: theme === 'light' })}
onClick={() => saveTheme('light')}
>
<Icon>
<Sun />
</Icon>
</Button>
<Button
className={classNames({ [styles.active]: theme === 'dark' })}
onClick={() => saveTheme('dark')}
>
<Icon>
<Moon />
</Icon>
</Button>
</div>
);
}
export default ThemeSetting;

View file

@ -1,8 +0,0 @@
.dropdown {
width: 200px;
}
div.menu {
max-height: 300px;
width: 300px;
}

View file

@ -1,57 +0,0 @@
import { Button, Icon, Icons, Modal, ModalTrigger, Text } from 'react-basics';
import { useApi, useMessages, useModified } from '@/components/hooks';
import ConfirmationForm from '@/components/common/ConfirmationForm';
export function ReportDeleteButton({
reportId,
reportName,
onDelete,
}: {
reportId: string;
reportName: string;
onDelete?: () => void;
}) {
const { formatMessage, labels, messages } = useMessages();
const { del, useMutation } = useApi();
const { mutate, isPending, error } = useMutation({
mutationFn: reportId => del(`/reports/${reportId}`),
});
const { touch } = useModified();
const handleConfirm = (close: () => void) => {
mutate(reportId as any, {
onSuccess: () => {
touch('reports');
onDelete?.();
close();
},
});
};
return (
<ModalTrigger>
<Button>
<Icon>
<Icons.Trash />
</Icon>
<Text>{formatMessage(labels.delete)}</Text>
</Button>
<Modal title={formatMessage(labels.deleteReport)}>
{(close: () => void) => (
<ConfirmationForm
message={formatMessage(messages.confirmDelete, {
target: <b key={messages.confirmDelete.id}>{reportName}</b>,
})}
isLoading={isPending}
error={error}
onConfirm={handleConfirm.bind(null, close)}
onClose={close}
buttonLabel={formatMessage(labels.delete)}
/>
)}
</Modal>
</ModalTrigger>
);
}
export default ReportDeleteButton;

View file

@ -1,22 +0,0 @@
import { useReports } from '@/components/hooks';
import ReportsTable from './ReportsTable';
import DataTable from '@/components/common/DataTable';
import { ReactNode } from 'react';
export default function ReportsDataTable({
websiteId,
teamId,
children,
}: {
websiteId?: string;
teamId?: string;
children?: ReactNode;
}) {
const queryResult = useReports({ websiteId, teamId });
return (
<DataTable queryResult={queryResult} renderEmpty={() => children}>
{({ data }) => <ReportsTable data={data} showDomain={!websiteId} />}
</DataTable>
);
}

View file

@ -1,27 +0,0 @@
import PageHeader from '@/components/layout/PageHeader';
import { Icon, Icons, Text } from 'react-basics';
import { useLogin, useMessages, useTeamUrl } from '@/components/hooks';
import LinkButton from '@/components/common/LinkButton';
import { ROLES } from '@/lib/constants';
export function ReportsHeader() {
const { formatMessage, labels } = useMessages();
const { renderTeamUrl } = useTeamUrl();
const { user } = useLogin();
const canEdit = user.role !== ROLES.viewOnly;
return (
<PageHeader title={formatMessage(labels.reports)}>
{canEdit && (
<LinkButton href={renderTeamUrl('/reports/create')} variant="primary">
<Icon>
<Icons.Plus />
</Icon>
<Text>{formatMessage(labels.createReport)}</Text>
</LinkButton>
)}
</PageHeader>
);
}
export default ReportsHeader;

Some files were not shown because too many files have changed in this diff Show more