diff options
Diffstat (limited to 'src/app/(main)')
233 files changed, 10611 insertions, 0 deletions
diff --git a/src/app/(main)/App.tsx b/src/app/(main)/App.tsx new file mode 100644 index 0000000..eada680 --- /dev/null +++ b/src/app/(main)/App.tsx @@ -0,0 +1,62 @@ +'use client'; +import { Column, Grid, Loading, Row } from '@umami/react-zen'; +import Script from 'next/script'; +import { useEffect } from 'react'; +import { MobileNav } from '@/app/(main)/MobileNav'; +import { SideNav } from '@/app/(main)/SideNav'; +import { useConfig, useLoginQuery, useNavigation } from '@/components/hooks'; +import { LAST_TEAM_CONFIG } from '@/lib/constants'; +import { removeItem, setItem } from '@/lib/storage'; +import { UpdateNotice } from './UpdateNotice'; + +export function App({ children }) { + const { user, isLoading, error } = useLoginQuery(); + const config = useConfig(); + const { pathname, teamId } = useNavigation(); + + useEffect(() => { + if (teamId) { + setItem(LAST_TEAM_CONFIG, teamId); + } else { + removeItem(LAST_TEAM_CONFIG); + } + }, [teamId]); + + if (isLoading || !config) { + return <Loading placement="absolute" />; + } + + if (error) { + window.location.href = config.cloudMode + ? `${process.env.cloudUrl}/login` + : `${process.env.basePath || ''}/login`; + return null; + } + + if (!user || !config) { + return null; + } + + return ( + <Grid + columns={{ xs: '1fr', lg: 'auto 1fr' }} + rows={{ xs: 'auto 1fr', lg: '1fr' }} + height={{ xs: 'auto', lg: '100vh' }} + width="100%" + > + <Row display={{ xs: 'flex', lg: 'none' }} alignItems="center" gap padding="3"> + <MobileNav /> + </Row> + <Column display={{ xs: 'none', lg: 'flex' }}> + <SideNav /> + </Column> + <Column alignItems="center" overflowY="auto" overflowX="hidden" 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> + ); +} diff --git a/src/app/(main)/MobileNav.tsx b/src/app/(main)/MobileNav.tsx new file mode 100644 index 0000000..aaa2584 --- /dev/null +++ b/src/app/(main)/MobileNav.tsx @@ -0,0 +1,71 @@ +import { Grid, IconLabel, NavMenu, NavMenuItem, Row, Text } from '@umami/react-zen'; +import Link from 'next/link'; +import { WebsiteNav } from '@/app/(main)/websites/[websiteId]/WebsiteNav'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { Globe, Grid2x2, LinkIcon } from '@/components/icons'; +import { MobileMenuButton } from '@/components/input/MobileMenuButton'; +import { NavButton } from '@/components/input/NavButton'; +import { Logo } from '@/components/svg'; +import { AdminNav } from './admin/AdminNav'; +import { SettingsNav } from './settings/SettingsNav'; + +export function MobileNav() { + const { formatMessage, labels } = useMessages(); + const { pathname, websiteId, renderUrl } = useNavigation(); + const isAdmin = pathname.includes('/admin'); + const isSettings = 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 />, + }, + ]; + + return ( + <Grid columns="auto 1fr" flexGrow={1} backgroundColor="3" borderRadius> + <MobileMenuButton> + {({ close }) => { + return ( + <> + <NavMenu padding="3" onItemClick={close} border="bottom"> + <NavButton /> + {links.map(link => { + return ( + <Link key={link.id} href={renderUrl(link.path)}> + <NavMenuItem> + <IconLabel icon={link.icon} label={link.label} /> + </NavMenuItem> + </Link> + ); + })} + </NavMenu> + {websiteId && <WebsiteNav websiteId={websiteId} onItemClick={close} />} + {isAdmin && <AdminNav onItemClick={close} />} + {isSettings && <SettingsNav onItemClick={close} />} + </> + ); + }} + </MobileMenuButton> + <Row alignItems="center" justifyContent="center" flexGrow={1}> + <IconLabel icon={<Logo />} style={{ width: 'auto' }}> + <Text weight="bold">umami</Text> + </IconLabel> + </Row> + </Grid> + ); +} diff --git a/src/app/(main)/SideNav.tsx b/src/app/(main)/SideNav.tsx new file mode 100644 index 0000000..1ecb58d --- /dev/null +++ b/src/app/(main)/SideNav.tsx @@ -0,0 +1,87 @@ +import { + Row, + Sidebar, + SidebarHeader, + SidebarItem, + type SidebarProps, + SidebarSection, + ThemeButton, +} from '@umami/react-zen'; +import Link from 'next/link'; +import type { Key } from 'react'; +import { useGlobalState, useMessages, useNavigation } from '@/components/hooks'; +import { Globe, Grid2x2, LinkIcon, PanelLeft } from '@/components/icons'; +import { LanguageButton } from '@/components/input/LanguageButton'; +import { NavButton } from '@/components/input/NavButton'; +import { PanelButton } from '@/components/input/PanelButton'; +import { Logo } from '@/components/svg'; + +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 ( + <Sidebar {...props} isCollapsed={isCollapsed || hasNav} backgroundColor> + <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> + ); +} diff --git a/src/app/(main)/TopNav.tsx b/src/app/(main)/TopNav.tsx new file mode 100644 index 0000000..d410097 --- /dev/null +++ b/src/app/(main)/TopNav.tsx @@ -0,0 +1,26 @@ +import { Row, ThemeButton } 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> + ); +} diff --git a/src/app/(main)/UpdateNotice.tsx b/src/app/(main)/UpdateNotice.tsx new file mode 100644 index 0000000..ef441d0 --- /dev/null +++ b/src/app/(main)/UpdateNotice.tsx @@ -0,0 +1,61 @@ +import { AlertBanner, Button, Column, Row } from '@umami/react-zen'; +import { usePathname } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { REPO_URL, VERSION_CHECK } from '@/lib/constants'; +import { setItem } from '@/lib/storage'; +import { checkVersion, useVersion } from '@/store/version'; + +export function UpdateNotice({ user, config }) { + const { formatMessage, labels, messages } = useMessages(); + const { latest, checked, hasUpdate, releaseUrl } = useVersion(); + const pathname = usePathname(); + const [dismissed, setDismissed] = useState(checked); + + const allowUpdate = + process.env.NODE_ENV === 'production' && + user?.isAdmin && + !config?.updatesDisabled && + !config?.privateMode && + !pathname.includes('/share/') && + !process.env.cloudMode && + !dismissed; + + const updateCheck = useCallback(() => { + setItem(VERSION_CHECK, { version: latest, time: Date.now() }); + }, [latest]); + + function handleViewClick() { + updateCheck(); + setDismissed(true); + open(releaseUrl || REPO_URL, '_blank'); + } + + function handleDismissClick() { + updateCheck(); + setDismissed(true); + } + + useEffect(() => { + if (allowUpdate) { + checkVersion(); + } + }, [allowUpdate]); + + if (!allowUpdate || !hasUpdate) { + return null; + } + + return ( + <Column justifyContent="center" alignItems="center" position="fixed" top="10px" width="100%"> + <Row width="600px"> + <AlertBanner title={formatMessage(messages.newVersionAvailable, { version: `v${latest}` })}> + <Button variant="primary" onPress={handleViewClick}> + {formatMessage(labels.viewDetails)} + </Button> + <Button onPress={handleDismissClick}>{formatMessage(labels.dismiss)}</Button> + </AlertBanner> + </Row> + </Column> + ); +} diff --git a/src/app/(main)/admin/AdminLayout.tsx b/src/app/(main)/admin/AdminLayout.tsx new file mode 100644 index 0000000..3c8fa20 --- /dev/null +++ b/src/app/(main)/admin/AdminLayout.tsx @@ -0,0 +1,33 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { PageBody } from '@/components/common/PageBody'; +import { useLoginQuery } from '@/components/hooks'; +import { AdminNav } from './AdminNav'; + +export function AdminLayout({ children }: { children: ReactNode }) { + const { user } = useLoginQuery(); + + if (!user.isAdmin || process.env.cloudMode) { + return null; + } + + return ( + <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%"> + <Column + display={{ xs: 'none', lg: 'flex' }} + width="240px" + height="100%" + border="right" + backgroundColor + marginRight="2" + padding="3" + > + <AdminNav /> + </Column> + <Column gap="6" margin="2"> + <PageBody>{children}</PageBody> + </Column> + </Grid> + ); +} diff --git a/src/app/(main)/admin/AdminNav.tsx b/src/app/(main)/admin/AdminNav.tsx new file mode 100644 index 0000000..20c0115 --- /dev/null +++ b/src/app/(main)/admin/AdminNav.tsx @@ -0,0 +1,48 @@ +import { SideMenu } from '@/components/common/SideMenu'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { Globe, User, Users } from '@/components/icons'; + +export function AdminNav({ onItemClick }: { onItemClick?: () => void }) { + const { formatMessage, labels } = useMessages(); + const { pathname } = useNavigation(); + + 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 ( + <SideMenu + items={items} + title={formatMessage(labels.admin)} + selectedKey={selectedKey} + allowMinimize={false} + onItemClick={onItemClick} + /> + ); +} diff --git a/src/app/(main)/admin/layout.tsx b/src/app/(main)/admin/layout.tsx new file mode 100644 index 0000000..34cdd0b --- /dev/null +++ b/src/app/(main)/admin/layout.tsx @@ -0,0 +1,17 @@ +import type { 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', + }, +}; diff --git a/src/app/(main)/admin/teams/AdminTeamsDataTable.tsx b/src/app/(main)/admin/teams/AdminTeamsDataTable.tsx new file mode 100644 index 0000000..7da8531 --- /dev/null +++ b/src/app/(main)/admin/teams/AdminTeamsDataTable.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; +import { DataGrid } from '@/components/common/DataGrid'; +import { useTeamsQuery } from '@/components/hooks'; +import { AdminTeamsTable } from './AdminTeamsTable'; + +export function AdminTeamsDataTable({ + showActions, +}: { + showActions?: boolean; + children?: ReactNode; +}) { + const queryResult = useTeamsQuery(); + + return ( + <DataGrid query={queryResult} allowSearch={true}> + {({ data }) => <AdminTeamsTable data={data} showActions={showActions} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/admin/teams/AdminTeamsPage.tsx b/src/app/(main)/admin/teams/AdminTeamsPage.tsx new file mode 100644 index 0000000..41e6f4a --- /dev/null +++ b/src/app/(main)/admin/teams/AdminTeamsPage.tsx @@ -0,0 +1,19 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { AdminTeamsDataTable } from './AdminTeamsDataTable'; + +export function AdminTeamsPage() { + const { formatMessage, labels } = useMessages(); + + return ( + <Column gap="6" margin="2"> + <PageHeader title={formatMessage(labels.teams)} /> + <Panel> + <AdminTeamsDataTable /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/admin/teams/AdminTeamsTable.tsx b/src/app/(main)/admin/teams/AdminTeamsTable.tsx new file mode 100644 index 0000000..9f2abd5 --- /dev/null +++ b/src/app/(main)/admin/teams/AdminTeamsTable.tsx @@ -0,0 +1,86 @@ +import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen'; +import Link from 'next/link'; +import { useState } from 'react'; +import { DateDistance } from '@/components/common/DateDistance'; +import { useMessages } from '@/components/hooks'; +import { Edit, Trash } from '@/components/icons'; +import { MenuButton } from '@/components/input/MenuButton'; +import { TeamDeleteForm } from '../../teams/[teamId]/TeamDeleteForm'; + +export function AdminTeamsTable({ + data = [], + showActions = true, +}: { + data: any[]; + showActions?: boolean; +}) { + const { formatMessage, labels } = useMessages(); + const [deleteTeam, setDeleteTeam] = 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={() => setDeleteTeam(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={!!deleteTeam}> + <Dialog style={{ width: 400 }}> + <TeamDeleteForm teamId={deleteTeam} onClose={() => setDeleteTeam(null)} /> + </Dialog> + </Modal> + </> + ); +} diff --git a/src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx b/src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx new file mode 100644 index 0000000..2150197 --- /dev/null +++ b/src/app/(main)/admin/teams/[teamId]/AdminTeamPage.tsx @@ -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> + ); +} diff --git a/src/app/(main)/admin/teams/[teamId]/page.tsx b/src/app/(main)/admin/teams/[teamId]/page.tsx new file mode 100644 index 0000000..104766a --- /dev/null +++ b/src/app/(main)/admin/teams/[teamId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { AdminTeamPage } from './AdminTeamPage'; + +export default async function ({ params }: { params: Promise<{ teamId: string }> }) { + const { teamId } = await params; + + return <AdminTeamPage teamId={teamId} />; +} + +export const metadata: Metadata = { + title: 'Team', +}; diff --git a/src/app/(main)/admin/teams/page.tsx b/src/app/(main)/admin/teams/page.tsx new file mode 100644 index 0000000..987f02b --- /dev/null +++ b/src/app/(main)/admin/teams/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from 'next'; +import { AdminTeamsPage } from './AdminTeamsPage'; + +export default function () { + return <AdminTeamsPage />; +} +export const metadata: Metadata = { + title: 'Teams', +}; diff --git a/src/app/(main)/admin/users/UserAddButton.tsx b/src/app/(main)/admin/users/UserAddButton.tsx new file mode 100644 index 0000000..0525082 --- /dev/null +++ b/src/app/(main)/admin/users/UserAddButton.tsx @@ -0,0 +1,32 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { UserAddForm } from './UserAddForm'; + +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> + ); +} diff --git a/src/app/(main)/admin/users/UserAddForm.tsx b/src/app/(main)/admin/users/UserAddForm.tsx new file mode 100644 index 0000000..6c36551 --- /dev/null +++ b/src/app/(main)/admin/users/UserAddForm.tsx @@ -0,0 +1,71 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + ListItem, + PasswordField, + Select, + TextField, +} 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> + ); +} diff --git a/src/app/(main)/admin/users/UserDeleteButton.tsx b/src/app/(main)/admin/users/UserDeleteButton.tsx new file mode 100644 index 0000000..ee8f2c1 --- /dev/null +++ b/src/app/(main)/admin/users/UserDeleteButton.tsx @@ -0,0 +1,35 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen'; +import { useLoginQuery, useMessages } 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> + ); +} diff --git a/src/app/(main)/admin/users/UserDeleteForm.tsx b/src/app/(main)/admin/users/UserDeleteForm.tsx new file mode 100644 index 0000000..8f6fd50 --- /dev/null +++ b/src/app/(main)/admin/users/UserDeleteForm.tsx @@ -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> + ); +} diff --git a/src/app/(main)/admin/users/UsersDataTable.tsx b/src/app/(main)/admin/users/UsersDataTable.tsx new file mode 100644 index 0000000..8467bd2 --- /dev/null +++ b/src/app/(main)/admin/users/UsersDataTable.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from 'react'; +import { DataGrid } from '@/components/common/DataGrid'; +import { useUsersQuery } from '@/components/hooks'; +import { UsersTable } from './UsersTable'; + +export function UsersDataTable({ showActions }: { showActions?: boolean; children?: ReactNode }) { + const queryResult = useUsersQuery(); + + return ( + <DataGrid query={queryResult} allowSearch={true}> + {({ data }) => <UsersTable data={data} showActions={showActions} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/admin/users/UsersPage.tsx b/src/app/(main)/admin/users/UsersPage.tsx new file mode 100644 index 0000000..7e1b0f4 --- /dev/null +++ b/src/app/(main)/admin/users/UsersPage.tsx @@ -0,0 +1,24 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { UserAddButton } from './UserAddButton'; +import { UsersDataTable } from './UsersDataTable'; + +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> + ); +} diff --git a/src/app/(main)/admin/users/UsersTable.tsx b/src/app/(main)/admin/users/UsersTable.tsx new file mode 100644 index 0000000..9c10f3e --- /dev/null +++ b/src/app/(main)/admin/users/UsersTable.tsx @@ -0,0 +1,84 @@ +import { DataColumn, DataTable, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen'; +import Link from 'next/link'; +import { useState } from 'react'; +import { DateDistance } from '@/components/common/DateDistance'; +import { useMessages } from '@/components/hooks'; +import { Edit, Trash } from '@/components/icons'; +import { MenuButton } from '@/components/input/MenuButton'; +import { ROLES } from '@/lib/constants'; +import { UserDeleteForm } from './UserDeleteForm'; + +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> + </> + ); +} diff --git a/src/app/(main)/admin/users/[userId]/UserEditForm.tsx b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx new file mode 100644 index 0000000..28bf030 --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserEditForm.tsx @@ -0,0 +1,73 @@ +import { + Form, + FormButtons, + FormField, + FormSubmitButton, + ListItem, + PasswordField, + Select, + TextField, +} 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('users'); + 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 defaultValue={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> + ); +} diff --git a/src/app/(main)/admin/users/[userId]/UserHeader.tsx b/src/app/(main)/admin/users/[userId]/UserHeader.tsx new file mode 100644 index 0000000..1f82897 --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserHeader.tsx @@ -0,0 +1,9 @@ +import { PageHeader } from '@/components/common/PageHeader'; +import { useUser } from '@/components/hooks'; +import { User } from '@/components/icons'; + +export function UserHeader() { + const user = useUser(); + + return <PageHeader title={user?.username} icon={<User />} />; +} diff --git a/src/app/(main)/admin/users/[userId]/UserPage.tsx b/src/app/(main)/admin/users/[userId]/UserPage.tsx new file mode 100644 index 0000000..5e0f8d1 --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserPage.tsx @@ -0,0 +1,19 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { UserHeader } from '@/app/(main)/admin/users/[userId]/UserHeader'; +import { Panel } from '@/components/common/Panel'; +import { UserProvider } from './UserProvider'; +import { UserSettings } from './UserSettings'; + +export function UserPage({ userId }: { userId: string }) { + return ( + <UserProvider userId={userId}> + <Column gap="6"> + <UserHeader /> + <Panel> + <UserSettings userId={userId} /> + </Panel> + </Column> + </UserProvider> + ); +} diff --git a/src/app/(main)/admin/users/[userId]/UserProvider.tsx b/src/app/(main)/admin/users/[userId]/UserProvider.tsx new file mode 100644 index 0000000..ea01915 --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserProvider.tsx @@ -0,0 +1,20 @@ +import { Loading } from '@umami/react-zen'; +import { createContext, type ReactNode } from 'react'; +import { useUserQuery } from '@/components/hooks/queries/useUserQuery'; +import type { User } from '@/generated/prisma/client'; + +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>; +} diff --git a/src/app/(main)/admin/users/[userId]/UserSettings.tsx b/src/app/(main)/admin/users/[userId]/UserSettings.tsx new file mode 100644 index 0000000..3f17f3e --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserSettings.tsx @@ -0,0 +1,25 @@ +import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { UserEditForm } from './UserEditForm'; +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> + ); +} diff --git a/src/app/(main)/admin/users/[userId]/UserWebsites.tsx b/src/app/(main)/admin/users/[userId]/UserWebsites.tsx new file mode 100644 index 0000000..eeb173e --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/UserWebsites.tsx @@ -0,0 +1,15 @@ +import { WebsitesTable } from '@/app/(main)/websites/WebsitesTable'; +import { DataGrid } from '@/components/common/DataGrid'; +import { useUserWebsitesQuery } from '@/components/hooks'; + +export function UserWebsites({ userId }) { + const queryResult = useUserWebsitesQuery({ userId }); + + return ( + <DataGrid query={queryResult}> + {({ data }) => ( + <WebsitesTable data={data} showActions={true} allowEdit={true} allowView={true} /> + )} + </DataGrid> + ); +} diff --git a/src/app/(main)/admin/users/[userId]/page.tsx b/src/app/(main)/admin/users/[userId]/page.tsx new file mode 100644 index 0000000..16c9f36 --- /dev/null +++ b/src/app/(main)/admin/users/[userId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { UserPage } from './UserPage'; + +export default async function ({ params }: { params: Promise<{ userId: string }> }) { + const { userId } = await params; + + return <UserPage userId={userId} />; +} + +export const metadata: Metadata = { + title: 'User', +}; diff --git a/src/app/(main)/admin/users/page.tsx b/src/app/(main)/admin/users/page.tsx new file mode 100644 index 0000000..96e69eb --- /dev/null +++ b/src/app/(main)/admin/users/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from 'next'; +import { UsersPage } from './UsersPage'; + +export default function () { + return <UsersPage />; +} +export const metadata: Metadata = { + title: 'Users', +}; diff --git a/src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx b/src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx new file mode 100644 index 0000000..2105992 --- /dev/null +++ b/src/app/(main)/admin/websites/AdminWebsitesDataTable.tsx @@ -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> + ); +} diff --git a/src/app/(main)/admin/websites/AdminWebsitesPage.tsx b/src/app/(main)/admin/websites/AdminWebsitesPage.tsx new file mode 100644 index 0000000..1c2ac92 --- /dev/null +++ b/src/app/(main)/admin/websites/AdminWebsitesPage.tsx @@ -0,0 +1,19 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { AdminWebsitesDataTable } from './AdminWebsitesDataTable'; + +export function AdminWebsitesPage() { + const { formatMessage, labels } = useMessages(); + + return ( + <Column gap="6" margin="2"> + <PageHeader title={formatMessage(labels.websites)} /> + <Panel> + <AdminWebsitesDataTable /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/admin/websites/AdminWebsitesTable.tsx b/src/app/(main)/admin/websites/AdminWebsitesTable.tsx new file mode 100644 index 0000000..cfda595 --- /dev/null +++ b/src/app/(main)/admin/websites/AdminWebsitesTable.tsx @@ -0,0 +1,89 @@ +import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from '@umami/react-zen'; +import Link from 'next/link'; +import { useState } from 'react'; +import { WebsiteDeleteForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm'; +import { DateDistance } from '@/components/common/DateDistance'; +import { useMessages } from '@/components/hooks'; +import { Edit, Trash, Users } from '@/components/icons'; +import { MenuButton } from '@/components/input/MenuButton'; + +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> + </> + ); +} diff --git a/src/app/(main)/admin/websites/[websiteId]/AdminWebsitePage.tsx b/src/app/(main)/admin/websites/[websiteId]/AdminWebsitePage.tsx new file mode 100644 index 0000000..5da82af --- /dev/null +++ b/src/app/(main)/admin/websites/[websiteId]/AdminWebsitePage.tsx @@ -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> + ); +} diff --git a/src/app/(main)/admin/websites/[websiteId]/page.tsx b/src/app/(main)/admin/websites/[websiteId]/page.tsx new file mode 100644 index 0000000..557adbd --- /dev/null +++ b/src/app/(main)/admin/websites/[websiteId]/page.tsx @@ -0,0 +1,12 @@ +import type { 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', +}; diff --git a/src/app/(main)/admin/websites/page.tsx b/src/app/(main)/admin/websites/page.tsx new file mode 100644 index 0000000..d6da9f6 --- /dev/null +++ b/src/app/(main)/admin/websites/page.tsx @@ -0,0 +1,9 @@ +import type { Metadata } from 'next'; +import { AdminWebsitesPage } from './AdminWebsitesPage'; + +export default function () { + return <AdminWebsitesPage />; +} +export const metadata: Metadata = { + title: 'Websites', +}; diff --git a/src/app/(main)/boards/BoardAddButton.tsx b/src/app/(main)/boards/BoardAddButton.tsx new file mode 100644 index 0000000..f9f80f4 --- /dev/null +++ b/src/app/(main)/boards/BoardAddButton.tsx @@ -0,0 +1,32 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen'; +import { useMessages, useModified, useNavigation } from '@/components/hooks'; +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> + ); +} diff --git a/src/app/(main)/boards/BoardAddForm.tsx b/src/app/(main)/boards/BoardAddForm.tsx new file mode 100644 index 0000000..6471b21 --- /dev/null +++ b/src/app/(main)/boards/BoardAddForm.tsx @@ -0,0 +1,60 @@ +import { Button, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen'; +import { useMessages, useUpdateQuery } 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> + ); +} diff --git a/src/app/(main)/boards/BoardsPage.tsx b/src/app/(main)/boards/BoardsPage.tsx new file mode 100644 index 0000000..fa5eb64 --- /dev/null +++ b/src/app/(main)/boards/BoardsPage.tsx @@ -0,0 +1,17 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { BoardAddButton } from './BoardAddButton'; + +export function BoardsPage() { + return ( + <PageBody> + <Column margin="2"> + <PageHeader title="My Boards"> + <BoardAddButton /> + </PageHeader> + </Column> + </PageBody> + ); +} diff --git a/src/app/(main)/boards/[boardId]/Board.tsx b/src/app/(main)/boards/[boardId]/Board.tsx new file mode 100644 index 0000000..93f24cc --- /dev/null +++ b/src/app/(main)/boards/[boardId]/Board.tsx @@ -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> + ); +} diff --git a/src/app/(main)/boards/[boardId]/page.tsx b/src/app/(main)/boards/[boardId]/page.tsx new file mode 100644 index 0000000..2cb076a --- /dev/null +++ b/src/app/(main)/boards/[boardId]/page.tsx @@ -0,0 +1,12 @@ +import type { 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', +}; diff --git a/src/app/(main)/boards/page.tsx b/src/app/(main)/boards/page.tsx new file mode 100644 index 0000000..e8ca662 --- /dev/null +++ b/src/app/(main)/boards/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { BoardsPage } from './BoardsPage'; + +export default function () { + return <BoardsPage />; +} + +export const metadata: Metadata = { + title: 'Boards', +}; diff --git a/src/app/(main)/console/[websiteId]/TestConsolePage.tsx b/src/app/(main)/console/[websiteId]/TestConsolePage.tsx new file mode 100644 index 0000000..56cc495 --- /dev/null +++ b/src/app/(main)/console/[websiteId]/TestConsolePage.tsx @@ -0,0 +1,207 @@ +'use client'; +import { Button, Column, Grid, Heading } from '@umami/react-zen'; +import Link from 'next/link'; +import Script from 'next/script'; +import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useWebsiteQuery } from '@/components/hooks'; +import { EventsChart } from '@/components/metrics/EventsChart'; + +export function TestConsolePage({ websiteId }: { websiteId: string }) { + const { data } = useWebsiteQuery(websiteId); + + function handleRunScript() { + window.umami.track(props => ({ + ...props, + url: '/page-view', + referrer: 'https://www.google.com', + })); + window.umami.track('track-event-no-data'); + window.umami.track('track-event-with-data', { + test: 'test-data', + boolean: true, + booleanError: 'true', + time: new Date(), + user: `user${Math.round(Math.random() * 10)}`, + number: 1, + number2: Math.random() * 100, + time2: new Date().toISOString(), + nested: { + test: 'test-data', + number: 1, + object: { + test: 'test-data', + }, + }, + array: [1, 2, 3], + }); + } + + function handleRunRevenue() { + window.umami.track(props => ({ + ...props, + url: '/checkout-cart', + referrer: 'https://www.google.com', + })); + window.umami.track('checkout-cart', { + revenue: parseFloat((Math.random() * 1000).toFixed(2)), + currency: 'USD', + }); + window.umami.track('affiliate-link', { + revenue: parseFloat((Math.random() * 1000).toFixed(2)), + currency: 'USD', + }); + window.umami.track('promotion-link', { + revenue: parseFloat((Math.random() * 1000).toFixed(2)), + currency: 'USD', + }); + window.umami.track('checkout-cart', { + revenue: parseFloat((Math.random() * 1000).toFixed(2)), + currency: 'EUR', + }); + window.umami.track('promotion-link', { + revenue: parseFloat((Math.random() * 1000).toFixed(2)), + currency: 'EUR', + }); + window.umami.track('affiliate-link', { + item1: { + productIdentity: 'ABC424', + revenue: parseFloat((Math.random() * 10000).toFixed(2)), + currency: 'JPY', + }, + item2: { + productIdentity: 'ZYW684', + revenue: parseFloat((Math.random() * 10000).toFixed(2)), + currency: 'JPY', + }, + }); + } + + function handleRunIdentify() { + window.umami.identify({ + userId: 123, + name: 'brian', + number: Math.random() * 100, + test: 'test-data', + boolean: true, + booleanError: 'true', + time: new Date(), + time2: new Date().toISOString(), + nested: { + test: 'test-data', + number: 1, + object: { + test: 'test-data', + }, + }, + array: [1, 2, 3], + }); + } + + if (!data) { + return null; + } + + return ( + <PageBody> + <PageHeader title="Test console"> + <Column>{data.name}</Column> + </PageHeader> + <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`}>page one</Link> + </div> + <div> + <Link href={`/console/${websiteId}?page=2 `}>page two</Link> + </div> + <div> + <a href="https://www.google.com" data-umami-event="external-link-direct"> + external link (direct) + </a> + </div> + <div> + <a + href="https://www.google.com" + data-umami-event="external-link-tab" + target="_blank" + rel="noreferrer" + > + external link (tab) + </a> + </div> + </Column> + <Column gap> + <Heading>Click events</Heading> + <Button id="send-event-button" data-umami-event="button-click" variant="primary"> + Send event + </Button> + <Button + id="send-event-data-button" + data-umami-event="button-click" + data-umami-event-name="bob" + data-umami-event-id="123" + variant="primary" + > + Send event with data + </Button> + <Button + id="generate-revenue-button" + data-umami-event="checkout-cart" + data-umami-event-revenue={(Math.random() * 10000).toFixed(2).toString()} + data-umami-event-currency="USD" + variant="primary" + > + Generate revenue data + </Button> + <Button + id="button-with-div-button" + data-umami-event="button-click" + data-umami-event-name={'bob'} + data-umami-event-id="123" + variant="primary" + > + <div>Button with div</div> + </Button> + <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> + </Column> + <Column gap> + <Heading>Javascript events</Heading> + <Button id="manual-button" variant="primary" onClick={handleRunScript}> + Run script + </Button> + <Button id="manual-button" variant="primary" onClick={handleRunIdentify}> + Run identify + </Button> + <Button id="manual-button" variant="primary" onClick={handleRunRevenue}> + Revenue script + </Button> + </Column> + </Grid> + </Panel> + <Heading>Pageviews</Heading> + <WebsiteChart websiteId={websiteId} /> + <Heading>Events</Heading> + <Panel> + <EventsChart websiteId={websiteId} /> + </Panel> + </Column> + </PageBody> + ); +} diff --git a/src/app/(main)/console/[websiteId]/page.tsx b/src/app/(main)/console/[websiteId]/page.tsx new file mode 100644 index 0000000..28b8161 --- /dev/null +++ b/src/app/(main)/console/[websiteId]/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next'; +import { TestConsolePage } from './TestConsolePage'; + +async function getEnabled() { + return !!process.env.ENABLE_TEST_CONSOLE; +} + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + const enabled = await getEnabled(); + + if (!enabled) { + return null; + } + + return <TestConsolePage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Test Console', +}; diff --git a/src/app/(main)/dashboard/DashboardPage.tsx b/src/app/(main)/dashboard/DashboardPage.tsx new file mode 100644 index 0000000..c2c7e75 --- /dev/null +++ b/src/app/(main)/dashboard/DashboardPage.tsx @@ -0,0 +1,17 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { useMessages } from '@/components/hooks'; + +export function DashboardPage() { + const { formatMessage, labels } = useMessages(); + + return ( + <PageBody> + <Column margin="2"> + <PageHeader title={formatMessage(labels.dashboard)}></PageHeader> + </Column> + </PageBody> + ); +} diff --git a/src/app/(main)/dashboard/page.tsx b/src/app/(main)/dashboard/page.tsx new file mode 100644 index 0000000..4b79b59 --- /dev/null +++ b/src/app/(main)/dashboard/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { DashboardPage } from './DashboardPage'; + +export default async function () { + return <DashboardPage />; +} + +export const metadata: Metadata = { + title: 'Dashboard', +}; diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx new file mode 100644 index 0000000..98fca4a --- /dev/null +++ b/src/app/(main)/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; +import { App } from './App'; + +export default function ({ children }) { + return ( + <Suspense> + <App>{children}</App> + </Suspense> + ); +} + +export const metadata: Metadata = { + title: { + template: '%s | Umami', + default: 'Umami', + }, +}; diff --git a/src/app/(main)/links/LinkAddButton.tsx b/src/app/(main)/links/LinkAddButton.tsx new file mode 100644 index 0000000..4276895 --- /dev/null +++ b/src/app/(main)/links/LinkAddButton.tsx @@ -0,0 +1,19 @@ +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { LinkEditForm } from './LinkEditForm'; + +export function LinkAddButton({ teamId }: { teamId?: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Plus />} + label={formatMessage(labels.addLink)} + variant="primary" + width="600px" + > + {({ close }) => <LinkEditForm teamId={teamId} onClose={close} />} + </DialogButton> + ); +} diff --git a/src/app/(main)/links/LinkDeleteButton.tsx b/src/app/(main)/links/LinkDeleteButton.tsx new file mode 100644 index 0000000..78f85f8 --- /dev/null +++ b/src/app/(main)/links/LinkDeleteButton.tsx @@ -0,0 +1,57 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { messages } from '@/components/messages'; + +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 ( + <DialogButton + icon={<Trash />} + title={formatMessage(labels.confirm)} + variant="quiet" + width="400px" + > + {({ 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" + /> + )} + </DialogButton> + ); +} diff --git a/src/app/(main)/links/LinkEditButton.tsx b/src/app/(main)/links/LinkEditButton.tsx new file mode 100644 index 0000000..4d85879 --- /dev/null +++ b/src/app/(main)/links/LinkEditButton.tsx @@ -0,0 +1,16 @@ +import { useMessages } from '@/components/hooks'; +import { Edit } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { LinkEditForm } from './LinkEditForm'; + +export function LinkEditButton({ linkId }: { linkId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton icon={<Edit />} title={formatMessage(labels.link)} variant="quiet" width="800px"> + {({ close }) => { + return <LinkEditForm linkId={linkId} onClose={close} />; + }} + </DialogButton> + ); +} diff --git a/src/app/(main)/links/LinkEditForm.tsx b/src/app/(main)/links/LinkEditForm.tsx new file mode 100644 index 0000000..6c10c7f --- /dev/null +++ b/src/app/(main)/links/LinkEditForm.tsx @@ -0,0 +1,148 @@ +import { + Button, + Column, + Form, + FormField, + FormSubmitButton, + Icon, + Label, + Loading, + Row, + TextField, +} from '@umami/react-zen'; +import { useEffect, useState } from 'react'; +import { useConfig, useLinkQuery, useMessages } from '@/components/hooks'; +import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery'; +import { RefreshCw } from '@/components/icons'; +import { LINKS_URL } from '@/lib/constants'; +import { getRandomChars } from '@/lib/generate'; +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> + ); +} diff --git a/src/app/(main)/links/LinkProvider.tsx b/src/app/(main)/links/LinkProvider.tsx new file mode 100644 index 0000000..c29e13c --- /dev/null +++ b/src/app/(main)/links/LinkProvider.tsx @@ -0,0 +1,21 @@ +'use client'; +import { Loading } from '@umami/react-zen'; +import { createContext, type ReactNode } from 'react'; +import { useLinkQuery } from '@/components/hooks/queries/useLinkQuery'; +import type { Link } from '@/generated/prisma/client'; + +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>; +} diff --git a/src/app/(main)/links/LinksDataTable.tsx b/src/app/(main)/links/LinksDataTable.tsx new file mode 100644 index 0000000..0b3d660 --- /dev/null +++ b/src/app/(main)/links/LinksDataTable.tsx @@ -0,0 +1,14 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useLinksQuery, useNavigation } from '@/components/hooks'; +import { LinksTable } from './LinksTable'; + +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> + ); +} diff --git a/src/app/(main)/links/LinksPage.tsx b/src/app/(main)/links/LinksPage.tsx new file mode 100644 index 0000000..a6e4c7c --- /dev/null +++ b/src/app/(main)/links/LinksPage.tsx @@ -0,0 +1,26 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { LinksDataTable } from '@/app/(main)/links/LinksDataTable'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { LinkAddButton } from './LinkAddButton'; + +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> + ); +} diff --git a/src/app/(main)/links/LinksTable.tsx b/src/app/(main)/links/LinksTable.tsx new file mode 100644 index 0000000..a3b4a86 --- /dev/null +++ b/src/app/(main)/links/LinksTable.tsx @@ -0,0 +1,51 @@ +import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen'; +import Link from 'next/link'; +import { DateDistance } from '@/components/common/DateDistance'; +import { ExternalLink } from '@/components/common/ExternalLink'; +import { useMessages, useNavigation, useSlug } from '@/components/hooks'; +import { LinkDeleteButton } from './LinkDeleteButton'; +import { LinkEditButton } from './LinkEditButton'; + +export function LinksTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { websiteId, renderUrl } = useNavigation(); + const { getSlugUrl } = useSlug('link'); + + return ( + <DataTable {...props}> + <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} prefetch={false}> + {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> + ); +} diff --git a/src/app/(main)/links/[linkId]/LinkControls.tsx b/src/app/(main)/links/[linkId]/LinkControls.tsx new file mode 100644 index 0000000..1d1147a --- /dev/null +++ b/src/app/(main)/links/[linkId]/LinkControls.tsx @@ -0,0 +1,32 @@ +import { Column, Row } from '@umami/react-zen'; +import { ExportButton } from '@/components/input/ExportButton'; +import { FilterBar } from '@/components/input/FilterBar'; +import { MonthFilter } from '@/components/input/MonthFilter'; +import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter'; +import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton'; + +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 && <MonthFilter />} + </Row> + {allowFilter && <FilterBar websiteId={websiteId} />} + </Column> + ); +} diff --git a/src/app/(main)/links/[linkId]/LinkHeader.tsx b/src/app/(main)/links/[linkId]/LinkHeader.tsx new file mode 100644 index 0000000..a84a626 --- /dev/null +++ b/src/app/(main)/links/[linkId]/LinkHeader.tsx @@ -0,0 +1,19 @@ +import { IconLabel } from '@umami/react-zen'; +import { LinkButton } from '@/components/common/LinkButton'; +import { PageHeader } from '@/components/common/PageHeader'; +import { useLink, useMessages, useSlug } from '@/components/hooks'; +import { ExternalLink, Link } from '@/components/icons'; + +export function LinkHeader() { + const { formatMessage, labels } = useMessages(); + const { getSlugUrl } = useSlug('link'); + const link = useLink(); + + return ( + <PageHeader title={link.name} description={link.url} icon={<Link />}> + <LinkButton href={getSlugUrl(link.slug)} target="_blank" prefetch={false} asAnchor> + <IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} /> + </LinkButton> + </PageHeader> + ); +} diff --git a/src/app/(main)/links/[linkId]/LinkMetricsBar.tsx b/src/app/(main)/links/[linkId]/LinkMetricsBar.tsx new file mode 100644 index 0000000..1fe8c45 --- /dev/null +++ b/src/app/(main)/links/[linkId]/LinkMetricsBar.tsx @@ -0,0 +1,70 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { formatLongNumber } from '@/lib/format'; + +export function LinkMetricsBar({ + linkId, +}: { + linkId: string; + showChange?: boolean; + compareMode?: boolean; +}) { + const { isAllTime } = useDateRange(); + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(linkId); + + 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> + ); +} diff --git a/src/app/(main)/links/[linkId]/LinkPage.tsx b/src/app/(main)/links/[linkId]/LinkPage.tsx new file mode 100644 index 0000000..ddacf08 --- /dev/null +++ b/src/app/(main)/links/[linkId]/LinkPage.tsx @@ -0,0 +1,34 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import { LinkControls } from '@/app/(main)/links/[linkId]/LinkControls'; +import { LinkHeader } from '@/app/(main)/links/[linkId]/LinkHeader'; +import { LinkMetricsBar } from '@/app/(main)/links/[linkId]/LinkMetricsBar'; +import { LinkPanels } from '@/app/(main)/links/[linkId]/LinkPanels'; +import { LinkProvider } from '@/app/(main)/links/LinkProvider'; +import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; +import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart'; +import { PageBody } from '@/components/common/PageBody'; +import { Panel } from '@/components/common/Panel'; + +const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event']; + +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> + <ExpandedViewModal websiteId={linkId} excludedIds={excludedIds} /> + </Column> + </Grid> + </LinkProvider> + ); +} diff --git a/src/app/(main)/links/[linkId]/LinkPanels.tsx b/src/app/(main)/links/[linkId]/LinkPanels.tsx new file mode 100644 index 0000000..f33525e --- /dev/null +++ b/src/app/(main)/links/[linkId]/LinkPanels.tsx @@ -0,0 +1,83 @@ +import { Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { GridRow } from '@/components/common/GridRow'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { MetricsTable } from '@/components/metrics/MetricsTable'; +import { WorldMap } from '@/components/metrics/WorldMap'; + +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 padding="0"> + <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> + ); +} diff --git a/src/app/(main)/links/[linkId]/page.tsx b/src/app/(main)/links/[linkId]/page.tsx new file mode 100644 index 0000000..4317ada --- /dev/null +++ b/src/app/(main)/links/[linkId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { LinkPage } from './LinkPage'; + +export default async function ({ params }: { params: Promise<{ linkId: string }> }) { + const { linkId } = await params; + + return <LinkPage linkId={linkId} />; +} + +export const metadata: Metadata = { + title: 'Link', +}; diff --git a/src/app/(main)/links/page.tsx b/src/app/(main)/links/page.tsx new file mode 100644 index 0000000..24c9c18 --- /dev/null +++ b/src/app/(main)/links/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { LinksPage } from './LinksPage'; + +export default function () { + return <LinksPage />; +} + +export const metadata: Metadata = { + title: 'Links', +}; diff --git a/src/app/(main)/pixels/PixelAddButton.tsx b/src/app/(main)/pixels/PixelAddButton.tsx new file mode 100644 index 0000000..1573b9e --- /dev/null +++ b/src/app/(main)/pixels/PixelAddButton.tsx @@ -0,0 +1,19 @@ +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { PixelEditForm } from './PixelEditForm'; + +export function PixelAddButton({ teamId }: { teamId?: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Plus />} + label={formatMessage(labels.addPixel)} + variant="primary" + width="600px" + > + {({ close }) => <PixelEditForm teamId={teamId} onClose={close} />} + </DialogButton> + ); +} diff --git a/src/app/(main)/pixels/PixelDeleteButton.tsx b/src/app/(main)/pixels/PixelDeleteButton.tsx new file mode 100644 index 0000000..436dba5 --- /dev/null +++ b/src/app/(main)/pixels/PixelDeleteButton.tsx @@ -0,0 +1,57 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages, useModified } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { messages } from '@/components/messages'; + +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 ( + <DialogButton + icon={<Trash />} + variant="quiet" + title={formatMessage(labels.confirm)} + width="400px" + > + {({ 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" + /> + )} + </DialogButton> + ); +} diff --git a/src/app/(main)/pixels/PixelEditButton.tsx b/src/app/(main)/pixels/PixelEditButton.tsx new file mode 100644 index 0000000..3c5924d --- /dev/null +++ b/src/app/(main)/pixels/PixelEditButton.tsx @@ -0,0 +1,21 @@ +import { useMessages } from '@/components/hooks'; +import { Edit } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { PixelEditForm } from './PixelEditForm'; + +export function PixelEditButton({ pixelId }: { pixelId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Edit />} + title={formatMessage(labels.addPixel)} + variant="quiet" + width="600px" + > + {({ close }) => { + return <PixelEditForm pixelId={pixelId} onClose={close} />; + }} + </DialogButton> + ); +} diff --git a/src/app/(main)/pixels/PixelEditForm.tsx b/src/app/(main)/pixels/PixelEditForm.tsx new file mode 100644 index 0000000..aedd3a3 --- /dev/null +++ b/src/app/(main)/pixels/PixelEditForm.tsx @@ -0,0 +1,129 @@ +import { + Button, + Column, + Form, + FormField, + FormSubmitButton, + Icon, + Label, + Loading, + Row, + TextField, +} from '@umami/react-zen'; +import { useEffect, useState } from 'react'; +import { useConfig, useMessages, usePixelQuery } from '@/components/hooks'; +import { useUpdateQuery } from '@/components/hooks/queries/useUpdateQuery'; +import { RefreshCw } from '@/components/icons'; +import { PIXELS_URL } from '@/lib/constants'; +import { getRandomChars } from '@/lib/generate'; + +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> + ); +} diff --git a/src/app/(main)/pixels/PixelProvider.tsx b/src/app/(main)/pixels/PixelProvider.tsx new file mode 100644 index 0000000..9e929d8 --- /dev/null +++ b/src/app/(main)/pixels/PixelProvider.tsx @@ -0,0 +1,21 @@ +'use client'; +import { Loading } from '@umami/react-zen'; +import { createContext, type ReactNode } from 'react'; +import { usePixelQuery } from '@/components/hooks/queries/usePixelQuery'; +import type { Pixel } from '@/generated/prisma/client'; + +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>; +} diff --git a/src/app/(main)/pixels/PixelsDataTable.tsx b/src/app/(main)/pixels/PixelsDataTable.tsx new file mode 100644 index 0000000..51b8c5a --- /dev/null +++ b/src/app/(main)/pixels/PixelsDataTable.tsx @@ -0,0 +1,14 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useNavigation, usePixelsQuery } from '@/components/hooks'; +import { PixelsTable } from './PixelsTable'; + +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> + ); +} diff --git a/src/app/(main)/pixels/PixelsPage.tsx b/src/app/(main)/pixels/PixelsPage.tsx new file mode 100644 index 0000000..4f6acef --- /dev/null +++ b/src/app/(main)/pixels/PixelsPage.tsx @@ -0,0 +1,26 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { PixelAddButton } from './PixelAddButton'; +import { PixelsDataTable } from './PixelsDataTable'; + +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> + ); +} diff --git a/src/app/(main)/pixels/PixelsTable.tsx b/src/app/(main)/pixels/PixelsTable.tsx new file mode 100644 index 0000000..48a8458 --- /dev/null +++ b/src/app/(main)/pixels/PixelsTable.tsx @@ -0,0 +1,48 @@ +import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen'; +import Link from 'next/link'; +import { DateDistance } from '@/components/common/DateDistance'; +import { ExternalLink } from '@/components/common/ExternalLink'; +import { useMessages, useNavigation, useSlug } from '@/components/hooks'; +import { PixelDeleteButton } from './PixelDeleteButton'; +import { PixelEditButton } from './PixelEditButton'; + +export function PixelsTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { renderUrl } = useNavigation(); + const { getSlugUrl } = useSlug('pixel'); + + return ( + <DataTable {...props}> + <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} prefetch={false}> + {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> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/PixelControls.tsx b/src/app/(main)/pixels/[pixelId]/PixelControls.tsx new file mode 100644 index 0000000..55dcd57 --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/PixelControls.tsx @@ -0,0 +1,32 @@ +import { Column, Row } from '@umami/react-zen'; +import { ExportButton } from '@/components/input/ExportButton'; +import { FilterBar } from '@/components/input/FilterBar'; +import { MonthFilter } from '@/components/input/MonthFilter'; +import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter'; +import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton'; + +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 && <MonthFilter />} + </Row> + {allowFilter && <FilterBar websiteId={websiteId} />} + </Column> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx b/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx new file mode 100644 index 0000000..c771687 --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/PixelHeader.tsx @@ -0,0 +1,19 @@ +import { IconLabel } from '@umami/react-zen'; +import { LinkButton } from '@/components/common/LinkButton'; +import { PageHeader } from '@/components/common/PageHeader'; +import { useMessages, usePixel, useSlug } from '@/components/hooks'; +import { ExternalLink, Grid2x2 } from '@/components/icons'; + +export function PixelHeader() { + const { formatMessage, labels } = useMessages(); + const { getSlugUrl } = useSlug('pixel'); + const pixel = usePixel(); + + return ( + <PageHeader title={pixel.name} icon={<Grid2x2 />}> + <LinkButton href={getSlugUrl(pixel.slug)} target="_blank" prefetch={false} asAnchor> + <IconLabel icon={<ExternalLink />} label={formatMessage(labels.view)} /> + </LinkButton> + </PageHeader> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx b/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx new file mode 100644 index 0000000..c9dcd35 --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/PixelMetricsBar.tsx @@ -0,0 +1,70 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { formatLongNumber } from '@/lib/format'; + +export function PixelMetricsBar({ + pixelId, +}: { + pixelId: string; + showChange?: boolean; + compareMode?: boolean; +}) { + const { isAllTime } = useDateRange(); + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(pixelId); + + 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> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/PixelPage.tsx b/src/app/(main)/pixels/[pixelId]/PixelPage.tsx new file mode 100644 index 0000000..7a4ae9d --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/PixelPage.tsx @@ -0,0 +1,34 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import { PixelControls } from '@/app/(main)/pixels/[pixelId]/PixelControls'; +import { PixelHeader } from '@/app/(main)/pixels/[pixelId]/PixelHeader'; +import { PixelMetricsBar } from '@/app/(main)/pixels/[pixelId]/PixelMetricsBar'; +import { PixelPanels } from '@/app/(main)/pixels/[pixelId]/PixelPanels'; +import { PixelProvider } from '@/app/(main)/pixels/PixelProvider'; +import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; +import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart'; +import { PageBody } from '@/components/common/PageBody'; +import { Panel } from '@/components/common/Panel'; + +const excludedIds = ['path', 'entry', 'exit', 'title', 'language', 'screen', 'event']; + +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> + <ExpandedViewModal websiteId={pixelId} excludedIds={excludedIds} /> + </Column> + </Grid> + </PixelProvider> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx b/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx new file mode 100644 index 0000000..9cc24c9 --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/PixelPanels.tsx @@ -0,0 +1,83 @@ +import { Grid, Heading, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { GridRow } from '@/components/common/GridRow'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { MetricsTable } from '@/components/metrics/MetricsTable'; +import { WorldMap } from '@/components/metrics/WorldMap'; + +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 padding="0"> + <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> + ); +} diff --git a/src/app/(main)/pixels/[pixelId]/page.tsx b/src/app/(main)/pixels/[pixelId]/page.tsx new file mode 100644 index 0000000..d1db92f --- /dev/null +++ b/src/app/(main)/pixels/[pixelId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { PixelPage } from './PixelPage'; + +export default async function ({ params }: { params: { pixelId: string } }) { + const { pixelId } = await params; + + return <PixelPage pixelId={pixelId} />; +} + +export const metadata: Metadata = { + title: 'Pixel', +}; diff --git a/src/app/(main)/pixels/page.tsx b/src/app/(main)/pixels/page.tsx new file mode 100644 index 0000000..cc240cd --- /dev/null +++ b/src/app/(main)/pixels/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { PixelsPage } from './PixelsPage'; + +export default function () { + return <PixelsPage />; +} + +export const metadata: Metadata = { + title: 'Pixels', +}; diff --git a/src/app/(main)/settings/SettingsLayout.tsx b/src/app/(main)/settings/SettingsLayout.tsx new file mode 100644 index 0000000..f658872 --- /dev/null +++ b/src/app/(main)/settings/SettingsLayout.tsx @@ -0,0 +1,26 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { PageBody } from '@/components/common/PageBody'; +import { SettingsNav } from './SettingsNav'; + +export function SettingsLayout({ children }: { children: ReactNode }) { + return ( + <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%"> + <Column + display={{ xs: 'none', lg: 'flex' }} + width="240px" + height="100%" + border="right" + backgroundColor + marginRight="2" + padding="3" + > + <SettingsNav /> + </Column> + <Column gap="6" margin="2"> + <PageBody>{children}</PageBody> + </Column> + </Grid> + ); +} diff --git a/src/app/(main)/settings/SettingsNav.tsx b/src/app/(main)/settings/SettingsNav.tsx new file mode 100644 index 0000000..4b35c82 --- /dev/null +++ b/src/app/(main)/settings/SettingsNav.tsx @@ -0,0 +1,53 @@ +import { SideMenu } from '@/components/common/SideMenu'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { Settings2, UserCircle, Users } from '@/components/icons'; + +export function SettingsNav({ onItemClick }: { onItemClick?: () => void }) { + const { formatMessage, labels } = useMessages(); + const { renderUrl, pathname } = useNavigation(); + + const items = [ + { + label: formatMessage(labels.application), + items: [ + { + id: 'preferences', + label: formatMessage(labels.preferences), + path: renderUrl('/settings/preferences'), + icon: <Settings2 />, + }, + ], + }, + { + label: formatMessage(labels.account), + items: [ + { + id: 'profile', + label: formatMessage(labels.profile), + path: renderUrl('/settings/profile'), + icon: <UserCircle />, + }, + { + id: 'teams', + label: formatMessage(labels.teams), + path: renderUrl('/settings/teams'), + icon: <Users />, + }, + ], + }, + ]; + + const selectedKey = items + .flatMap(e => e.items) + .find(({ path }) => path && pathname.includes(path.split('?')[0]))?.id; + + return ( + <SideMenu + items={items} + title={formatMessage(labels.settings)} + selectedKey={selectedKey} + allowMinimize={false} + onItemClick={onItemClick} + /> + ); +} diff --git a/src/app/(main)/settings/layout.tsx b/src/app/(main)/settings/layout.tsx new file mode 100644 index 0000000..4e773a3 --- /dev/null +++ b/src/app/(main)/settings/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next'; +import { SettingsLayout } from './SettingsLayout'; + +export default function ({ children }) { + if (process.env.cloudMode) { + return null; + } + + return <SettingsLayout>{children}</SettingsLayout>; +} + +export const metadata: Metadata = { + title: { + template: '%s | Settings | Umami', + default: 'Settings | Umami', + }, +}; diff --git a/src/app/(main)/settings/preferences/DateRangeSetting.tsx b/src/app/(main)/settings/preferences/DateRangeSetting.tsx new file mode 100644 index 0000000..3f5e664 --- /dev/null +++ b/src/app/(main)/settings/preferences/DateRangeSetting.tsx @@ -0,0 +1,28 @@ +import { Button, Row } from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { DateFilter } from '@/components/input/DateFilter'; +import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants'; +import { getItem, setItem } from '@/lib/storage'; + +export function DateRangeSetting() { + const { formatMessage, labels } = useMessages(); + const [date, setDate] = useState(getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE); + + const handleChange = (value: string) => { + setItem(DATE_RANGE_CONFIG, value); + setDate(value); + }; + + const handleReset = () => { + setItem(DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE); + setDate(DEFAULT_DATE_RANGE_VALUE); + }; + + return ( + <Row gap="3"> + <DateFilter value={date} onChange={handleChange} placement="bottom start" /> + <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button> + </Row> + ); +} diff --git a/src/app/(main)/settings/preferences/LanguageSetting.tsx b/src/app/(main)/settings/preferences/LanguageSetting.tsx new file mode 100644 index 0000000..00a2d74 --- /dev/null +++ b/src/app/(main)/settings/preferences/LanguageSetting.tsx @@ -0,0 +1,48 @@ +import { Button, ListItem, Row, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { useLocale, useMessages } from '@/components/hooks'; +import { DEFAULT_LOCALE } from '@/lib/constants'; +import { languages } from '@/lib/lang'; + +export function LanguageSetting() { + const [search, setSearch] = useState(''); + const { formatMessage, labels } = useMessages(); + const { locale, saveLocale } = useLocale(); + const items = search + ? Object.keys(languages).filter(n => { + return ( + n.toLowerCase().includes(search.toLowerCase()) || + languages[n].label.toLowerCase().includes(search.toLowerCase()) + ); + }) + : Object.keys(languages); + + const handleReset = () => saveLocale(DEFAULT_LOCALE); + + const handleOpen = (isOpen: boolean) => { + if (isOpen) { + setSearch(''); + } + }; + + return ( + <Row gap> + <Select + value={locale} + onChange={val => saveLocale(val as string)} + allowSearch + onSearch={setSearch} + onOpenChange={handleOpen} + listProps={{ style: { maxHeight: 300 } }} + > + {items.map(item => ( + <ListItem key={item} id={item}> + {languages[item].label} + </ListItem> + ))} + {!items.length && <ListItem></ListItem>} + </Select> + <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button> + </Row> + ); +} diff --git a/src/app/(main)/settings/preferences/PreferenceSettings.tsx b/src/app/(main)/settings/preferences/PreferenceSettings.tsx new file mode 100644 index 0000000..a2890ce --- /dev/null +++ b/src/app/(main)/settings/preferences/PreferenceSettings.tsx @@ -0,0 +1,36 @@ +import { Column, Label } from '@umami/react-zen'; +import { useLoginQuery, useMessages } from '@/components/hooks'; +import { DateRangeSetting } from './DateRangeSetting'; +import { LanguageSetting } from './LanguageSetting'; +import { ThemeSetting } from './ThemeSetting'; +import { TimezoneSetting } from './TimezoneSetting'; + +export function PreferenceSettings() { + const { user } = useLoginQuery(); + const { formatMessage, labels } = useMessages(); + + if (!user) { + return null; + } + + return ( + <Column width="400px" gap="6"> + <Column> + <Label>{formatMessage(labels.defaultDateRange)}</Label> + <DateRangeSetting /> + </Column> + <Column> + <Label>{formatMessage(labels.timezone)}</Label> + <TimezoneSetting /> + </Column> + <Column> + <Label>{formatMessage(labels.language)}</Label> + <LanguageSetting /> + </Column> + <Column> + <Label>{formatMessage(labels.theme)}</Label> + <ThemeSetting /> + </Column> + </Column> + ); +} diff --git a/src/app/(main)/settings/preferences/PreferencesPage.tsx b/src/app/(main)/settings/preferences/PreferencesPage.tsx new file mode 100644 index 0000000..61e2669 --- /dev/null +++ b/src/app/(main)/settings/preferences/PreferencesPage.tsx @@ -0,0 +1,22 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { PreferenceSettings } from './PreferenceSettings'; + +export function PreferencesPage() { + const { formatMessage, labels } = useMessages(); + + return ( + <PageBody> + <Column gap="6"> + <PageHeader title={formatMessage(labels.preferences)} /> + <Panel> + <PreferenceSettings /> + </Panel> + </Column> + </PageBody> + ); +} diff --git a/src/app/(main)/settings/preferences/ThemeSetting.tsx b/src/app/(main)/settings/preferences/ThemeSetting.tsx new file mode 100644 index 0000000..03bd6a6 --- /dev/null +++ b/src/app/(main)/settings/preferences/ThemeSetting.tsx @@ -0,0 +1,21 @@ +import { Button, Icon, Row, useTheme } from '@umami/react-zen'; +import { Moon, Sun } from '@/components/icons'; + +export function ThemeSetting() { + const { theme, setTheme } = useTheme(); + + return ( + <Row gap> + <Button variant={theme === 'light' ? 'primary' : undefined} onPress={() => setTheme('light')}> + <Icon> + <Sun /> + </Icon> + </Button> + <Button variant={theme === 'dark' ? 'primary' : undefined} onPress={() => setTheme('dark')}> + <Icon> + <Moon /> + </Icon> + </Button> + </Row> + ); +} diff --git a/src/app/(main)/settings/preferences/TimezoneSetting.tsx b/src/app/(main)/settings/preferences/TimezoneSetting.tsx new file mode 100644 index 0000000..cf20b20 --- /dev/null +++ b/src/app/(main)/settings/preferences/TimezoneSetting.tsx @@ -0,0 +1,44 @@ +import { Button, ListItem, Row, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages, useTimezone } from '@/components/hooks'; +import { getTimezone } from '@/lib/date'; + +const timezones = Intl.supportedValuesOf('timeZone'); + +export function TimezoneSetting() { + const [search, setSearch] = useState(''); + const { formatMessage, labels } = useMessages(); + const { timezone, saveTimezone } = useTimezone(); + const items = search + ? timezones.filter(n => n.toLowerCase().includes(search.toLowerCase())) + : timezones; + + const handleReset = () => saveTimezone(getTimezone()); + + const handleOpen = isOpen => { + if (isOpen) { + setSearch(''); + } + }; + + return ( + <Row gap> + <Select + value={timezone} + onChange={(value: any) => saveTimezone(value)} + allowSearch={true} + onSearch={setSearch} + onOpenChange={handleOpen} + listProps={{ style: { maxHeight: 300 } }} + > + {items.map((item: any) => ( + <ListItem key={item} id={item}> + {item} + </ListItem> + ))} + {!items.length && <ListItem></ListItem>} + </Select> + <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button> + </Row> + ); +} diff --git a/src/app/(main)/settings/preferences/page.tsx b/src/app/(main)/settings/preferences/page.tsx new file mode 100644 index 0000000..dd16870 --- /dev/null +++ b/src/app/(main)/settings/preferences/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { PreferencesPage } from './PreferencesPage'; + +export default function () { + return <PreferencesPage />; +} + +export const metadata: Metadata = { + title: 'Preferences', +}; diff --git a/src/app/(main)/settings/profile/PasswordChangeButton.tsx b/src/app/(main)/settings/profile/PasswordChangeButton.tsx new file mode 100644 index 0000000..6ce8ef8 --- /dev/null +++ b/src/app/(main)/settings/profile/PasswordChangeButton.tsx @@ -0,0 +1,29 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { LockKeyhole } from '@/components/icons'; +import { PasswordEditForm } from './PasswordEditForm'; + +export function PasswordChangeButton() { + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + + const handleSave = () => { + toast(formatMessage(messages.saved)); + }; + + return ( + <DialogTrigger> + <Button> + <Icon> + <LockKeyhole /> + </Icon> + <Text>{formatMessage(labels.changePassword)}</Text> + </Button> + <Modal> + <Dialog title={formatMessage(labels.changePassword)} style={{ width: 400 }}> + {({ close }) => <PasswordEditForm onSave={handleSave} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/settings/profile/PasswordEditForm.tsx b/src/app/(main)/settings/profile/PasswordEditForm.tsx new file mode 100644 index 0000000..6f782e4 --- /dev/null +++ b/src/app/(main)/settings/profile/PasswordEditForm.tsx @@ -0,0 +1,67 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + PasswordField, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; + +export function PasswordEditForm({ onSave, onClose }) { + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending } = useUpdateQuery('/me/password'); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + onSave(); + onClose(); + }, + }); + }; + + const samePassword = (value: string, values: Record<string, any>) => { + if (value !== values.newPassword) { + return formatMessage(messages.noMatchPassword); + } + return true; + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)}> + <FormField + label={formatMessage(labels.currentPassword)} + name="currentPassword" + rules={{ required: 'Required' }} + > + <PasswordField autoComplete="current-password" /> + </FormField> + <FormField + name="newPassword" + label={formatMessage(labels.newPassword)} + rules={{ + required: 'Required', + minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) }, + }} + > + <PasswordField autoComplete="new-password" /> + </FormField> + <FormField + name="confirmPassword" + label={formatMessage(labels.confirmPassword)} + rules={{ + required: formatMessage(labels.required), + minLength: { value: 8, message: formatMessage(messages.minPasswordLength, { n: '8' }) }, + validate: samePassword, + }} + > + <PasswordField autoComplete="confirm-password" /> + </FormField> + <FormButtons> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <FormSubmitButton isDisabled={isPending}>{formatMessage(labels.save)}</FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/settings/profile/ProfileHeader.tsx b/src/app/(main)/settings/profile/ProfileHeader.tsx new file mode 100644 index 0000000..05f7996 --- /dev/null +++ b/src/app/(main)/settings/profile/ProfileHeader.tsx @@ -0,0 +1,8 @@ +import { SectionHeader } from '@/components/common/SectionHeader'; +import { useMessages } from '@/components/hooks'; + +export function ProfileHeader() { + const { formatMessage, labels } = useMessages(); + + return <SectionHeader title={formatMessage(labels.profile)}></SectionHeader>; +} diff --git a/src/app/(main)/settings/profile/ProfilePage.tsx b/src/app/(main)/settings/profile/ProfilePage.tsx new file mode 100644 index 0000000..f03499a --- /dev/null +++ b/src/app/(main)/settings/profile/ProfilePage.tsx @@ -0,0 +1,22 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { ProfileSettings } from './ProfileSettings'; + +export function ProfilePage() { + const { formatMessage, labels } = useMessages(); + + return ( + <PageBody> + <Column gap="6"> + <PageHeader title={formatMessage(labels.profile)} /> + <Panel> + <ProfileSettings /> + </Panel> + </Column> + </PageBody> + ); +} diff --git a/src/app/(main)/settings/profile/ProfileSettings.tsx b/src/app/(main)/settings/profile/ProfileSettings.tsx new file mode 100644 index 0000000..fae73a5 --- /dev/null +++ b/src/app/(main)/settings/profile/ProfileSettings.tsx @@ -0,0 +1,51 @@ +import { Column, Label, Row } from '@umami/react-zen'; +import { useConfig, useLoginQuery, useMessages } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; +import { PasswordChangeButton } from './PasswordChangeButton'; + +export function ProfileSettings() { + const { user } = useLoginQuery(); + const { formatMessage, labels } = useMessages(); + const { cloudMode } = useConfig(); + + 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 ( + <Column width="400px" gap="6"> + <Column> + <Label>{formatMessage(labels.username)}</Label> + {username} + </Column> + <Column> + <Label>{formatMessage(labels.role)}</Label> + {renderRole(role)} + </Column> + {!cloudMode && ( + <Column> + <Label>{formatMessage(labels.password)}</Label> + <Row> + <PasswordChangeButton /> + </Row> + </Column> + )} + </Column> + ); +} diff --git a/src/app/(main)/settings/profile/page.tsx b/src/app/(main)/settings/profile/page.tsx new file mode 100644 index 0000000..6060b91 --- /dev/null +++ b/src/app/(main)/settings/profile/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { ProfilePage } from './ProfilePage'; + +export default function () { + return <ProfilePage />; +} + +export const metadata: Metadata = { + title: 'Profile', +}; diff --git a/src/app/(main)/settings/teams/TeamsSettingsPage.tsx b/src/app/(main)/settings/teams/TeamsSettingsPage.tsx new file mode 100644 index 0000000..dc3e3bc --- /dev/null +++ b/src/app/(main)/settings/teams/TeamsSettingsPage.tsx @@ -0,0 +1,16 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { TeamsDataTable } from '@/app/(main)/teams/TeamsDataTable'; +import { TeamsHeader } from '@/app/(main)/teams/TeamsHeader'; +import { Panel } from '@/components/common/Panel'; + +export function TeamsSettingsPage() { + return ( + <Column gap="6"> + <TeamsHeader /> + <Panel> + <TeamsDataTable /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx b/src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx new file mode 100644 index 0000000..9539625 --- /dev/null +++ b/src/app/(main)/settings/teams/[teamId]/TeamSettingsPage.tsx @@ -0,0 +1,11 @@ +'use client'; +import { TeamSettings } from '@/app/(main)/teams/[teamId]/TeamSettings'; +import { TeamProvider } from '@/app/(main)/teams/TeamProvider'; + +export function TeamSettingsPage({ teamId }: { teamId: string }) { + return ( + <TeamProvider teamId={teamId}> + <TeamSettings teamId={teamId} /> + </TeamProvider> + ); +} diff --git a/src/app/(main)/settings/teams/[teamId]/page.tsx b/src/app/(main)/settings/teams/[teamId]/page.tsx new file mode 100644 index 0000000..58a380b --- /dev/null +++ b/src/app/(main)/settings/teams/[teamId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { TeamSettingsPage } from './TeamSettingsPage'; + +export default async function ({ params }: { params: Promise<{ teamId: string }> }) { + const { teamId } = await params; + + return <TeamSettingsPage teamId={teamId} />; +} + +export const metadata: Metadata = { + title: 'Teams', +}; diff --git a/src/app/(main)/settings/teams/page.tsx b/src/app/(main)/settings/teams/page.tsx new file mode 100644 index 0000000..a0913f4 --- /dev/null +++ b/src/app/(main)/settings/teams/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { TeamsSettingsPage } from './TeamsSettingsPage'; + +export default function () { + return <TeamsSettingsPage />; +} + +export const metadata: Metadata = { + title: 'Teams', +}; diff --git a/src/app/(main)/settings/websites/WebsitesSettingsPage.tsx b/src/app/(main)/settings/websites/WebsitesSettingsPage.tsx new file mode 100644 index 0000000..5009ec6 --- /dev/null +++ b/src/app/(main)/settings/websites/WebsitesSettingsPage.tsx @@ -0,0 +1,16 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsitesDataTable } from '@/app/(main)/websites/WebsitesDataTable'; +import { SectionHeader } from '@/components/common/SectionHeader'; +import { useMessages } from '@/components/hooks'; + +export function WebsitesSettingsPage({ teamId }: { teamId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <Column gap> + <SectionHeader title={formatMessage(labels.websites)} /> + <WebsitesDataTable teamId={teamId} /> + </Column> + ); +} diff --git a/src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx new file mode 100644 index 0000000..53b4cd9 --- /dev/null +++ b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage.tsx @@ -0,0 +1,16 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteSettings } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings'; +import { WebsiteSettingsHeader } from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader'; +import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider'; + +export function WebsiteSettingsPage({ websiteId }: { websiteId: string }) { + return ( + <WebsiteProvider websiteId={websiteId}> + <Column margin="2"> + <WebsiteSettingsHeader /> + <WebsiteSettings websiteId={websiteId} /> + </Column> + </WebsiteProvider> + ); +} diff --git a/src/app/(main)/settings/websites/[websiteId]/page.tsx b/src/app/(main)/settings/websites/[websiteId]/page.tsx new file mode 100644 index 0000000..9adfc91 --- /dev/null +++ b/src/app/(main)/settings/websites/[websiteId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { WebsiteSettingsPage } from './WebsiteSettingsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <WebsiteSettingsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Website', +}; diff --git a/src/app/(main)/settings/websites/page.tsx b/src/app/(main)/settings/websites/page.tsx new file mode 100644 index 0000000..19c14fd --- /dev/null +++ b/src/app/(main)/settings/websites/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { WebsitesSettingsPage } from './WebsitesSettingsPage'; + +export default async function ({ params }: { params: Promise<{ teamId: string }> }) { + const { teamId } = await params; + + return <WebsitesSettingsPage teamId={teamId} />; +} + +export const metadata: Metadata = { + title: 'Websites', +}; diff --git a/src/app/(main)/teams/TeamAddForm.tsx b/src/app/(main)/teams/TeamAddForm.tsx new file mode 100644 index 0000000..c95259f --- /dev/null +++ b/src/app/(main)/teams/TeamAddForm.tsx @@ -0,0 +1,39 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + TextField, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; + +export function TeamAddForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) { + const { formatMessage, labels, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending } = useUpdateQuery('/teams'); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)}> + <FormField name="name" label={formatMessage(labels.name)}> + <TextField autoComplete="off" /> + </FormField> + <FormButtons> + <Button isDisabled={isPending} onPress={onClose}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton variant="primary" isDisabled={isPending}> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/teams/TeamJoinForm.tsx b/src/app/(main)/teams/TeamJoinForm.tsx new file mode 100644 index 0000000..6978078 --- /dev/null +++ b/src/app/(main)/teams/TeamJoinForm.tsx @@ -0,0 +1,40 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + TextField, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; + +export function TeamJoinForm({ onSave, onClose }: { onSave: () => void; onClose: () => void }) { + const { formatMessage, labels, getErrorMessage } = useMessages(); + const { mutateAsync, error, touch } = useUpdateQuery('/teams/join'); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + touch('teams:members'); + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)}> + <FormField + label={formatMessage(labels.accessCode)} + name="accessCode" + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoComplete="off" /> + </FormField> + <FormButtons> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <FormSubmitButton variant="primary">{formatMessage(labels.join)}</FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/teams/TeamLeaveButton.tsx b/src/app/(main)/teams/TeamLeaveButton.tsx new file mode 100644 index 0000000..2cca76f --- /dev/null +++ b/src/app/(main)/teams/TeamLeaveButton.tsx @@ -0,0 +1,41 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen'; +import { useRouter } from 'next/navigation'; +import { useLoginQuery, useMessages, useModified } from '@/components/hooks'; +import { LogOut } from '@/components/icons'; +import { TeamLeaveForm } from './TeamLeaveForm'; + +export function TeamLeaveButton({ teamId, teamName }: { teamId: string; teamName: string }) { + const { formatMessage, labels } = useMessages(); + const router = useRouter(); + const { user } = useLoginQuery(); + const { touch } = useModified(); + + const handleLeave = async () => { + touch('teams'); + router.push('/settings/teams'); + }; + + return ( + <DialogTrigger> + <Button> + <Icon> + <LogOut /> + </Icon> + <Text>{formatMessage(labels.leave)}</Text> + </Button> + <Modal> + <Dialog title={formatMessage(labels.leaveTeam)} style={{ width: 400 }}> + {({ close }) => ( + <TeamLeaveForm + teamId={teamId} + userId={user.id} + teamName={teamName} + onSave={handleLeave} + onClose={close} + /> + )} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/teams/TeamLeaveForm.tsx b/src/app/(main)/teams/TeamLeaveForm.tsx new file mode 100644 index 0000000..b3dcaf5 --- /dev/null +++ b/src/app/(main)/teams/TeamLeaveForm.tsx @@ -0,0 +1,48 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages, useModified } from '@/components/hooks'; + +export function TeamLeaveForm({ + teamId, + userId, + teamName, + onSave, + onClose, +}: { + teamId: string; + userId: string; + teamName: string; + onSave: () => void; + onClose: () => void; +}) { + const { formatMessage, labels, messages, getErrorMessage, FormattedMessage } = useMessages(); + const { mutateAsync, error, isPending } = useDeleteQuery(`/teams/${teamId}/users/${userId}`); + const { touch } = useModified(); + + const handleConfirm = async () => { + await mutateAsync(null, { + onSuccess: async () => { + touch('teams:members'); + onSave(); + onClose(); + }, + }); + }; + + return ( + <ConfirmationForm + buttonLabel={formatMessage(labels.leave)} + message={ + <FormattedMessage + {...messages.confirmLeave} + values={{ + target: <b>{teamName}</b>, + }} + /> + } + onConfirm={handleConfirm} + onClose={onClose} + isLoading={isPending} + error={getErrorMessage(error)} + /> + ); +} diff --git a/src/app/(main)/teams/TeamProvider.tsx b/src/app/(main)/teams/TeamProvider.tsx new file mode 100644 index 0000000..cea4161 --- /dev/null +++ b/src/app/(main)/teams/TeamProvider.tsx @@ -0,0 +1,21 @@ +'use client'; +import { Loading } from '@umami/react-zen'; +import { createContext, type ReactNode } from 'react'; +import { useTeamQuery } from '@/components/hooks/queries/useTeamQuery'; +import type { Team } from '@/generated/prisma/client'; + +export const TeamContext = createContext<Team>(null); + +export function TeamProvider({ teamId, children }: { teamId?: string; children: ReactNode }) { + const { data: team, isLoading, isFetching } = useTeamQuery(teamId); + + if (isFetching && isLoading) { + return <Loading placement="absolute" />; + } + + if (!team) { + return null; + } + + return <TeamContext.Provider value={team}>{children}</TeamContext.Provider>; +} diff --git a/src/app/(main)/teams/TeamsAddButton.tsx b/src/app/(main)/teams/TeamsAddButton.tsx new file mode 100644 index 0000000..578a273 --- /dev/null +++ b/src/app/(main)/teams/TeamsAddButton.tsx @@ -0,0 +1,33 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { messages } from '@/components/messages'; +import { TeamAddForm } from './TeamAddForm'; + +export function TeamsAddButton({ onSave }: { onSave?: () => void }) { + const { formatMessage, labels } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = async () => { + toast(formatMessage(messages.saved)); + touch('teams'); + onSave?.(); + }; + + return ( + <DialogTrigger> + <Button variant="primary"> + <Icon> + <Plus /> + </Icon> + <Text>{formatMessage(labels.createTeam)}</Text> + </Button> + <Modal> + <Dialog title={formatMessage(labels.createTeam)} style={{ width: 400 }}> + {({ close }) => <TeamAddForm onSave={handleSave} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/teams/TeamsDataTable.tsx b/src/app/(main)/teams/TeamsDataTable.tsx new file mode 100644 index 0000000..cdce7b9 --- /dev/null +++ b/src/app/(main)/teams/TeamsDataTable.tsx @@ -0,0 +1,27 @@ +import Link from 'next/link'; +import { DataGrid } from '@/components/common/DataGrid'; +import { useLoginQuery, useNavigation, useUserTeamsQuery } from '@/components/hooks'; +import { TeamsTable } from './TeamsTable'; + +export function TeamsDataTable() { + const { user } = useLoginQuery(); + const query = useUserTeamsQuery(user.id); + const { pathname } = useNavigation(); + const isSettings = pathname.includes('/settings'); + + const renderLink = (row: any) => { + return ( + <Link key={row.id} href={`${isSettings ? '/settings' : ''}/teams/${row.id}`}> + {row.name} + </Link> + ); + }; + + return ( + <DataGrid query={query}> + {({ data }) => { + return <TeamsTable data={data} renderLink={renderLink} />; + }} + </DataGrid> + ); +} diff --git a/src/app/(main)/teams/TeamsHeader.tsx b/src/app/(main)/teams/TeamsHeader.tsx new file mode 100644 index 0000000..579ba59 --- /dev/null +++ b/src/app/(main)/teams/TeamsHeader.tsx @@ -0,0 +1,26 @@ +import { Row } from '@umami/react-zen'; +import { PageHeader } from '@/components/common/PageHeader'; +import { useLoginQuery, useMessages } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; +import { TeamsAddButton } from './TeamsAddButton'; +import { TeamsJoinButton } from './TeamsJoinButton'; + +export function TeamsHeader({ + allowCreate = true, + allowJoin = true, +}: { + allowCreate?: boolean; + allowJoin?: boolean; +}) { + const { formatMessage, labels } = useMessages(); + const { user } = useLoginQuery(); + + return ( + <PageHeader title={formatMessage(labels.teams)}> + <Row gap="3"> + {allowJoin && <TeamsJoinButton />} + {allowCreate && user.role !== ROLES.viewOnly && <TeamsAddButton />} + </Row> + </PageHeader> + ); +} diff --git a/src/app/(main)/teams/TeamsJoinButton.tsx b/src/app/(main)/teams/TeamsJoinButton.tsx new file mode 100644 index 0000000..017211e --- /dev/null +++ b/src/app/(main)/teams/TeamsJoinButton.tsx @@ -0,0 +1,31 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text, useToast } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { UserPlus } from '@/components/icons'; +import { TeamJoinForm } from './TeamJoinForm'; + +export function TeamsJoinButton() { + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleJoin = () => { + toast(formatMessage(messages.saved)); + touch('teams'); + }; + + return ( + <DialogTrigger> + <Button> + <Icon> + <UserPlus /> + </Icon> + <Text>{formatMessage(labels.joinTeam)}</Text> + </Button> + <Modal> + <Dialog title={formatMessage(labels.joinTeam)} style={{ width: 400 }}> + {({ close }) => <TeamJoinForm onSave={handleJoin} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/teams/TeamsPage.tsx b/src/app/(main)/teams/TeamsPage.tsx new file mode 100644 index 0000000..5b11bcf --- /dev/null +++ b/src/app/(main)/teams/TeamsPage.tsx @@ -0,0 +1,19 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { TeamsDataTable } from '@/app/(main)/teams/TeamsDataTable'; +import { TeamsHeader } from '@/app/(main)/teams/TeamsHeader'; +import { PageBody } from '@/components/common/PageBody'; +import { Panel } from '@/components/common/Panel'; + +export function TeamsPage() { + return ( + <PageBody> + <Column gap="6"> + <TeamsHeader /> + <Panel> + <TeamsDataTable /> + </Panel> + </Column> + </PageBody> + ); +} diff --git a/src/app/(main)/teams/TeamsTable.tsx b/src/app/(main)/teams/TeamsTable.tsx new file mode 100644 index 0000000..754f0b2 --- /dev/null +++ b/src/app/(main)/teams/TeamsTable.tsx @@ -0,0 +1,29 @@ +import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { useMessages } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; + +export interface TeamsTableProps extends DataTableProps { + renderLink?: (row: any) => ReactNode; +} + +export function TeamsTable({ renderLink, ...props }: TeamsTableProps) { + const { formatMessage, labels } = useMessages(); + + return ( + <DataTable {...props}> + <DataColumn id="name" label={formatMessage(labels.name)}> + {renderLink} + </DataColumn> + <DataColumn id="owner" label={formatMessage(labels.owner)}> + {(row: any) => row?.members?.find(({ role }) => role === ROLES.teamOwner)?.user?.username} + </DataColumn> + <DataColumn id="members" label={formatMessage(labels.members)} align="end"> + {(row: any) => row?._count?.members} + </DataColumn> + <DataColumn id="websites" label={formatMessage(labels.websites)} align="end"> + {(row: any) => row?._count?.websites} + </DataColumn> + </DataTable> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx b/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx new file mode 100644 index 0000000..7adc9b3 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx @@ -0,0 +1,40 @@ +import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; + +const CONFIRM_VALUE = 'DELETE'; + +export function TeamDeleteForm({ + teamId, + onSave, + onClose, +}: { + teamId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { labels, formatMessage, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending, touch } = useDeleteQuery(`/teams/${teamId}`); + + const handleConfirm = async () => { + await mutateAsync(null, { + onSuccess: async () => { + touch('teams'); + touch(`teams:${teamId}`); + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <TypeConfirmationForm + confirmationValue={CONFIRM_VALUE} + onConfirm={handleConfirm} + onClose={onClose} + isLoading={isPending} + error={getErrorMessage(error)} + buttonLabel={formatMessage(labels.delete)} + buttonVariant="danger" + /> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamEditForm.tsx b/src/app/(main)/teams/[teamId]/TeamEditForm.tsx new file mode 100644 index 0000000..74e038f --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamEditForm.tsx @@ -0,0 +1,89 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + IconLabel, + Row, + TextField, +} from '@umami/react-zen'; +import { useMessages, useTeam, useUpdateQuery } from '@/components/hooks'; +import { RefreshCw } from '@/components/icons'; +import { getRandomChars } from '@/lib/generate'; + +const generateId = () => `team_${getRandomChars(16)}`; + +export function TeamEditForm({ + teamId, + allowEdit, + showAccessCode, + onSave, +}: { + teamId: string; + allowEdit?: boolean; + showAccessCode?: boolean; + onSave?: () => void; +}) { + const team = useTeam(); + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + + const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/teams/${teamId}`); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch('teams'); + touch(`teams:${teamId}`); + onSave?.(); + }, + }); + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ ...team }}> + {({ setValue }) => { + return ( + <> + <FormField name="id" label={formatMessage(labels.teamId)}> + <TextField isReadOnly allowCopy /> + </FormField> + <FormField + name="name" + label={formatMessage(labels.name)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField isReadOnly={!allowEdit} /> + </FormField> + {showAccessCode && ( + <Row alignItems="flex-end" gap> + <FormField + name="accessCode" + label={formatMessage(labels.accessCode)} + style={{ flex: 1 }} + > + <TextField isReadOnly allowCopy /> + </FormField> + {allowEdit && ( + <Button + onPress={() => setValue('accessCode', generateId(), { shouldDirty: true })} + > + <IconLabel icon={<RefreshCw />} label={formatMessage(labels.regenerate)} /> + </Button> + )} + </Row> + )} + {allowEdit && ( + <FormButtons justifyContent="flex-end"> + <FormSubmitButton variant="primary" isPending={isPending}> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + )} + </> + ); + }} + </Form> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamManage.tsx b/src/app/(main)/teams/[teamId]/TeamManage.tsx new file mode 100644 index 0000000..88cbad9 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamManage.tsx @@ -0,0 +1,32 @@ +import { Button, Dialog, DialogTrigger, Modal } from '@umami/react-zen'; +import { useRouter } from 'next/navigation'; +import { ActionForm } from '@/components/common/ActionForm'; +import { useMessages, useModified } from '@/components/hooks'; +import { TeamDeleteForm } from './TeamDeleteForm'; + +export function TeamManage({ teamId }: { teamId: string }) { + const { formatMessage, labels, messages } = useMessages(); + const router = useRouter(); + const { touch } = useModified(); + + const handleLeave = async () => { + touch('teams'); + router.push('/settings/teams'); + }; + + return ( + <ActionForm + label={formatMessage(labels.deleteTeam)} + description={formatMessage(messages.deleteTeamWarning)} + > + <DialogTrigger> + <Button variant="danger">{formatMessage(labels.delete)}</Button> + <Modal> + <Dialog title={formatMessage(labels.deleteTeam)} style={{ width: 400 }}> + {({ close }) => <TeamDeleteForm teamId={teamId} onSave={handleLeave} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + </ActionForm> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx b/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx new file mode 100644 index 0000000..f75b6d1 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx @@ -0,0 +1,46 @@ +import { useToast } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Edit } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { TeamMemberEditForm } from './TeamMemberEditForm'; + +export function TeamMemberEditButton({ + teamId, + userId, + role, + onSave, +}: { + teamId: string; + userId: string; + role: string; + onSave?: () => void; +}) { + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = () => { + touch('teams:members'); + toast(formatMessage(messages.saved)); + onSave?.(); + }; + + return ( + <DialogButton + icon={<Edit />} + title={formatMessage(labels.editMember)} + variant="quiet" + width="400px" + > + {({ close }) => ( + <TeamMemberEditForm + teamId={teamId} + userId={userId} + role={role} + onSave={handleSave} + onClose={close} + /> + )} + </DialogButton> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx b/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx new file mode 100644 index 0000000..4826746 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx @@ -0,0 +1,62 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + ListItem, + Select, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; + +export function TeamMemberEditForm({ + teamId, + userId, + role, + onSave, + onClose, +}: { + teamId: string; + userId: string; + role: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users/${userId}`); + const { formatMessage, labels, getErrorMessage } = useMessages(); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + onSave(); + onClose(); + }, + }); + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ role }}> + <FormField + name="role" + rules={{ required: formatMessage(labels.required) }} + label={formatMessage(labels.role)} + > + <Select> + <ListItem id={ROLES.teamManager}>{formatMessage(labels.manager)}</ListItem> + <ListItem id={ROLES.teamMember}>{formatMessage(labels.member)}</ListItem> + <ListItem id={ROLES.teamViewOnly}>{formatMessage(labels.viewOnly)}</ListItem> + </Select> + </FormField> + + <FormButtons> + <Button isDisabled={isPending} onPress={onClose}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton variant="primary" isDisabled={false}> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx b/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx new file mode 100644 index 0000000..4d3e8e9 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx @@ -0,0 +1,60 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages, useModified } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { messages } from '@/components/messages'; + +export function TeamMemberRemoveButton({ + teamId, + userId, + userName, + onSave, +}: { + teamId: string; + userId: string; + userName: string; + disabled?: boolean; + onSave?: () => void; +}) { + const { formatMessage, labels, FormattedMessage } = useMessages(); + const { mutateAsync, isPending, error } = useDeleteQuery(`/teams/${teamId}/users/${userId}`); + const { touch } = useModified(); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: () => { + touch('teams:members'); + onSave?.(); + close(); + }, + }); + }; + + return ( + <DialogButton + icon={<Trash />} + title={formatMessage(labels.confirm)} + variant="quiet" + width="400px" + > + {({ close }) => ( + <ConfirmationForm + message={ + <FormattedMessage + {...messages.confirmRemove} + values={{ + target: <b>{userName}</b>, + }} + /> + } + isLoading={isPending} + error={error} + onConfirm={handleConfirm.bind(null, close)} + onClose={close} + buttonLabel={formatMessage(labels.remove)} + buttonVariant="danger" + /> + )} + </DialogButton> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx b/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx new file mode 100644 index 0000000..52c0fe3 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx @@ -0,0 +1,19 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useTeamMembersQuery } from '@/components/hooks'; +import { TeamMembersTable } from './TeamMembersTable'; + +export function TeamMembersDataTable({ + teamId, + allowEdit = false, +}: { + teamId: string; + allowEdit?: boolean; +}) { + const queryResult = useTeamMembersQuery(teamId); + + return ( + <DataGrid query={queryResult} allowSearch> + {({ data }) => <TeamMembersTable data={data} teamId={teamId} allowEdit={allowEdit} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx b/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx new file mode 100644 index 0000000..8414908 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx @@ -0,0 +1,55 @@ +import { DataColumn, DataTable, Row } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; +import { TeamMemberEditButton } from './TeamMemberEditButton'; +import { TeamMemberRemoveButton } from './TeamMemberRemoveButton'; + +export function TeamMembersTable({ + data = [], + teamId, + allowEdit = false, +}: { + data: any[]; + teamId: string; + allowEdit: boolean; +}) { + const { formatMessage, labels } = useMessages(); + + const roles = { + [ROLES.teamOwner]: formatMessage(labels.teamOwner), + [ROLES.teamManager]: formatMessage(labels.teamManager), + [ROLES.teamMember]: formatMessage(labels.teamMember), + [ROLES.teamViewOnly]: formatMessage(labels.viewOnly), + }; + + return ( + <DataTable data={data}> + <DataColumn id="username" label={formatMessage(labels.username)}> + {(row: any) => row?.user?.username} + </DataColumn> + <DataColumn id="role" label={formatMessage(labels.role)}> + {(row: any) => roles[row?.role]} + </DataColumn> + {allowEdit && ( + <DataColumn id="action" align="end"> + {(row: any) => { + if (row?.role === ROLES.teamOwner) { + return null; + } + + return ( + <Row alignItems="center" maxHeight="20px"> + <TeamMemberEditButton teamId={teamId} userId={row?.user?.id} role={row?.role} /> + <TeamMemberRemoveButton + teamId={teamId} + userId={row?.user?.id} + userName={row?.user?.username} + /> + </Row> + ); + }} + </DataColumn> + )} + </DataTable> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamSettings.tsx b/src/app/(main)/teams/[teamId]/TeamSettings.tsx new file mode 100644 index 0000000..3ddbe00 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamSettings.tsx @@ -0,0 +1,49 @@ +import { Column } from '@umami/react-zen'; +import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks'; +import { Users } from '@/components/icons'; +import { ROLES } from '@/lib/constants'; +import { TeamEditForm } from './TeamEditForm'; +import { TeamManage } from './TeamManage'; +import { TeamMembersDataTable } from './TeamMembersDataTable'; + +export function TeamSettings({ teamId }: { teamId: string }) { + const team: any = useTeam(); + const { user } = useLoginQuery(); + const { pathname } = useNavigation(); + + const isAdmin = pathname.includes('/admin'); + + const isTeamOwner = + !!team?.members?.find(({ userId, role }) => role === ROLES.teamOwner && userId === user.id) && + user.role !== ROLES.viewOnly; + + const canEdit = + user.isAdmin || + (!!team?.members?.find( + ({ userId, role }) => + (role === ROLES.teamOwner || role === ROLES.teamManager) && userId === user.id, + ) && + user.role !== ROLES.viewOnly); + + return ( + <Column gap="6"> + <PageHeader title={team?.name} icon={<Users />}> + {!isTeamOwner && !isAdmin && <TeamLeaveButton teamId={team.id} teamName={team.name} />} + </PageHeader> + <Panel> + <TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} /> + </Panel> + <Panel> + <TeamMembersDataTable teamId={teamId} allowEdit={canEdit} /> + </Panel> + {isTeamOwner && ( + <Panel> + <TeamManage teamId={teamId} /> + </Panel> + )} + </Column> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx b/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx new file mode 100644 index 0000000..f2b4ece --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx @@ -0,0 +1,25 @@ +import { Icon, LoadingButton, Text } from '@umami/react-zen'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; +import { X } from '@/components/icons'; + +export function TeamWebsiteRemoveButton({ teamId, websiteId, onSave }) { + const { formatMessage, labels } = useMessages(); + const { mutateAsync } = useDeleteQuery(`/teams/${teamId}/websites/${websiteId}`); + + const handleRemoveTeamMember = async () => { + await mutateAsync(null, { + onSuccess: () => { + onSave(); + }, + }); + }; + + return ( + <LoadingButton variant="quiet" onClick={() => handleRemoveTeamMember()}> + <Icon> + <X /> + </Icon> + <Text>{formatMessage(labels.remove)}</Text> + </LoadingButton> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx b/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx new file mode 100644 index 0000000..6a2e4f4 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx @@ -0,0 +1,19 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useTeamWebsitesQuery } from '@/components/hooks'; +import { TeamWebsitesTable } from './TeamWebsitesTable'; + +export function TeamWebsitesDataTable({ + teamId, + allowEdit = false, +}: { + teamId: string; + allowEdit?: boolean; +}) { + const queryResult = useTeamWebsitesQuery(teamId); + + return ( + <DataGrid query={queryResult} allowSearch> + {({ data }) => <TeamWebsitesTable data={data} teamId={teamId} allowEdit={allowEdit} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx b/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx new file mode 100644 index 0000000..10f5654 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx @@ -0,0 +1,50 @@ +import { DataColumn, DataTable, Row } from '@umami/react-zen'; +import Link from 'next/link'; +import { TeamMemberEditButton } from '@/app/(main)/teams/[teamId]/TeamMemberEditButton'; +import { TeamMemberRemoveButton } from '@/app/(main)/teams/[teamId]/TeamMemberRemoveButton'; +import { useMessages } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; + +export function TeamWebsitesTable({ + teamId, + data = [], + allowEdit, +}: { + teamId: string; + data: any[]; + allowEdit: boolean; +}) { + const { formatMessage, labels } = useMessages(); + + return ( + <DataTable data={data}> + <DataColumn id="name" label={formatMessage(labels.name)}> + {(row: any) => <Link href={`/teams/${teamId}/websites/${row.id}`}>{row.name}</Link>} + </DataColumn> + <DataColumn id="domain" label={formatMessage(labels.domain)} /> + <DataColumn id="createdBy" label={formatMessage(labels.createdBy)}> + {(row: any) => row?.createUser?.username} + </DataColumn> + {allowEdit && ( + <DataColumn id="action" align="end"> + {(row: any) => { + if (row?.role === ROLES.teamOwner) { + return null; + } + + return ( + <Row alignItems="center"> + <TeamMemberEditButton teamId={teamId} userId={row?.user?.id} role={row?.role} /> + <TeamMemberRemoveButton + teamId={teamId} + userId={row?.user?.id} + userName={row?.user?.username} + /> + </Row> + ); + }} + </DataColumn> + )} + </DataTable> + ); +} diff --git a/src/app/(main)/teams/page.tsx b/src/app/(main)/teams/page.tsx new file mode 100644 index 0000000..7344f15 --- /dev/null +++ b/src/app/(main)/teams/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { TeamsPage } from './TeamsPage'; + +export default function () { + return <TeamsPage />; +} + +export const metadata: Metadata = { + title: 'Teams', +}; diff --git a/src/app/(main)/websites/WebsiteAddButton.tsx b/src/app/(main)/websites/WebsiteAddButton.tsx new file mode 100644 index 0000000..76710ab --- /dev/null +++ b/src/app/(main)/websites/WebsiteAddButton.tsx @@ -0,0 +1,28 @@ +import { useToast } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { WebsiteAddForm } from './WebsiteAddForm'; + +export function WebsiteAddButton({ teamId, onSave }: { teamId: string; onSave?: () => void }) { + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = async () => { + toast(formatMessage(messages.saved)); + touch('websites'); + onSave?.(); + }; + + return ( + <DialogButton + icon={<Plus />} + label={formatMessage(labels.addWebsite)} + variant="primary" + width="400px" + > + {({ close }) => <WebsiteAddForm teamId={teamId} onSave={handleSave} onClose={close} />} + </DialogButton> + ); +} diff --git a/src/app/(main)/websites/WebsiteAddForm.tsx b/src/app/(main)/websites/WebsiteAddForm.tsx new file mode 100644 index 0000000..df17ad5 --- /dev/null +++ b/src/app/(main)/websites/WebsiteAddForm.tsx @@ -0,0 +1,60 @@ +import { Button, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { DOMAIN_REGEX } from '@/lib/constants'; + +export function WebsiteAddForm({ + 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> + ); +} diff --git a/src/app/(main)/websites/WebsiteProvider.tsx b/src/app/(main)/websites/WebsiteProvider.tsx new file mode 100644 index 0000000..75e8a35 --- /dev/null +++ b/src/app/(main)/websites/WebsiteProvider.tsx @@ -0,0 +1,27 @@ +'use client'; +import { Loading } from '@umami/react-zen'; +import { createContext, type ReactNode } from 'react'; +import { useWebsiteQuery } from '@/components/hooks/queries/useWebsiteQuery'; +import type { Website } from '@/generated/prisma/client'; + +export const WebsiteContext = createContext<Website>(null); + +export function WebsiteProvider({ + websiteId, + children, +}: { + websiteId: string; + children: ReactNode; +}) { + const { data: website, isFetching, isLoading } = useWebsiteQuery(websiteId); + + if (isFetching && isLoading) { + return <Loading placement="absolute" />; + } + + if (!website) { + return null; + } + + return <WebsiteContext.Provider value={website}>{children}</WebsiteContext.Provider>; +} diff --git a/src/app/(main)/websites/WebsitesDataTable.tsx b/src/app/(main)/websites/WebsitesDataTable.tsx new file mode 100644 index 0000000..3f0a6b9 --- /dev/null +++ b/src/app/(main)/websites/WebsitesDataTable.tsx @@ -0,0 +1,47 @@ +import Link from 'next/link'; +import { DataGrid } from '@/components/common/DataGrid'; +import { useLoginQuery, useNavigation, useUserWebsitesQuery } from '@/components/hooks'; +import { Favicon } from '@/index'; +import { Icon, Row } from '@umami/react-zen'; +import { WebsitesTable } from './WebsitesTable'; + +export function WebsitesDataTable({ + userId, + teamId, + allowEdit = true, + allowView = true, + showActions = true, +}: { + userId?: string; + teamId?: string; + allowEdit?: boolean; + allowView?: boolean; + showActions?: boolean; +}) { + const { user } = useLoginQuery(); + const queryResult = useUserWebsitesQuery({ userId: userId || user?.id, teamId }); + const { renderUrl } = useNavigation(); + + const renderLink = (row: any) => ( + <Row alignItems="center" gap="3"> + <Icon size="md" color="muted"> + <Favicon domain={row.domain} /> + </Icon> + <Link href={renderUrl(`/websites/${row.id}`, false)}>{row.name}</Link> + </Row> + ); + + return ( + <DataGrid query={queryResult} allowSearch allowPaging> + {({ data }) => ( + <WebsitesTable + data={data} + showActions={showActions} + allowEdit={allowEdit} + allowView={allowView} + renderLink={renderLink} + /> + )} + </DataGrid> + ); +} diff --git a/src/app/(main)/websites/WebsitesHeader.tsx b/src/app/(main)/websites/WebsitesHeader.tsx new file mode 100644 index 0000000..889b602 --- /dev/null +++ b/src/app/(main)/websites/WebsitesHeader.tsx @@ -0,0 +1,18 @@ +import { PageHeader } from '@/components/common/PageHeader'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { WebsiteAddButton } from './WebsiteAddButton'; + +export interface WebsitesHeaderProps { + allowCreate?: boolean; +} + +export function WebsitesHeader({ allowCreate = true }: WebsitesHeaderProps) { + const { formatMessage, labels } = useMessages(); + const { teamId } = useNavigation(); + + return ( + <PageHeader title={formatMessage(labels.websites)}> + {allowCreate && <WebsiteAddButton teamId={teamId} />} + </PageHeader> + ); +} diff --git a/src/app/(main)/websites/WebsitesPage.tsx b/src/app/(main)/websites/WebsitesPage.tsx new file mode 100644 index 0000000..31de704 --- /dev/null +++ b/src/app/(main)/websites/WebsitesPage.tsx @@ -0,0 +1,26 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { PageBody } from '@/components/common/PageBody'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { WebsiteAddButton } from './WebsiteAddButton'; +import { WebsitesDataTable } from './WebsitesDataTable'; + +export function WebsitesPage() { + const { teamId } = useNavigation(); + const { formatMessage, labels } = useMessages(); + + return ( + <PageBody> + <Column gap="6" margin="2"> + <PageHeader title={formatMessage(labels.websites)}> + <WebsiteAddButton teamId={teamId} /> + </PageHeader> + <Panel> + <WebsitesDataTable teamId={teamId} /> + </Panel> + </Column> + </PageBody> + ); +} diff --git a/src/app/(main)/websites/WebsitesTable.tsx b/src/app/(main)/websites/WebsitesTable.tsx new file mode 100644 index 0000000..70648ed --- /dev/null +++ b/src/app/(main)/websites/WebsitesTable.tsx @@ -0,0 +1,41 @@ +import { DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { LinkButton } from '@/components/common/LinkButton'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { SquarePen } from '@/components/icons'; + +export interface WebsitesTableProps extends DataTableProps { + showActions?: boolean; + allowEdit?: boolean; + allowView?: boolean; + renderLink?: (row: any) => ReactNode; +} + +export function WebsitesTable({ showActions, renderLink, ...props }: WebsitesTableProps) { + const { formatMessage, labels } = useMessages(); + const { renderUrl } = useNavigation(); + + return ( + <DataTable {...props}> + <DataColumn id="name" label={formatMessage(labels.name)}> + {renderLink} + </DataColumn> + <DataColumn id="domain" label={formatMessage(labels.domain)} /> + {showActions && ( + <DataColumn id="action" label=" " align="end"> + {(row: any) => { + const websiteId = row.id; + + return ( + <LinkButton href={renderUrl(`/websites/${websiteId}/settings`)} variant="quiet"> + <Icon> + <SquarePen /> + </Icon> + </LinkButton> + ); + }} + </DataColumn> + )} + </DataTable> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx new file mode 100644 index 0000000..264923a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution.tsx @@ -0,0 +1,128 @@ +import { Column, Grid } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { SectionHeader } from '@/components/common/SectionHeader'; +import { useMessages, useResultQuery } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { percentFilter } from '@/lib/filters'; +import { formatLongNumber } from '@/lib/format'; + +export interface AttributionProps { + websiteId: string; + startDate: Date; + endDate: Date; + model: string; + type: string; + step: string; + currency?: string; +} + +export function Attribution({ + websiteId, + startDate, + endDate, + model, + type, + step, + currency, +}: AttributionProps) { + const { data, error, isLoading } = useResultQuery<any>('attribution', { + websiteId, + startDate, + endDate, + model, + type, + step, + }); + + const { formatMessage, labels } = useMessages(); + + const { pageviews, visitors, visits } = data?.total || {}; + + const metrics = data + ? [ + { + value: visitors, + label: formatMessage(labels.visitors), + formatValue: formatLongNumber, + }, + { + value: visits, + label: formatMessage(labels.visits), + formatValue: formatLongNumber, + }, + { + value: pageviews, + label: formatMessage(labels.views), + formatValue: formatLongNumber, + }, + ] + : []; + + function AttributionTable({ data = [], title }: { data: any; title: string }) { + const attributionData = percentFilter( + data.map(({ name, value }) => ({ + x: name, + y: Number(value), + })), + ); + + return ( + <ListTable + title={title} + metric={formatMessage(currency ? labels.revenue : labels.visitors)} + currency={currency} + data={attributionData.map(({ x, y, z }: { x: string; y: number; z: number }) => ({ + label: x, + count: y, + percent: z, + }))} + /> + ); + } + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Column gap> + <MetricsBar> + {metrics?.map(({ label, value, formatValue }) => { + return ( + <MetricCard key={label} value={value} label={label} formatValue={formatValue} /> + ); + })} + </MetricsBar> + <SectionHeader title={formatMessage(labels.sources)} /> + <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> + <Panel> + <AttributionTable data={data?.referrer} title={formatMessage(labels.referrer)} /> + </Panel> + <Panel> + <AttributionTable data={data?.paidAds} title={formatMessage(labels.paidAds)} /> + </Panel> + </Grid> + <SectionHeader title="UTM" /> + <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> + <Panel> + <AttributionTable data={data?.utm_source} title={formatMessage(labels.sources)} /> + </Panel> + <Panel> + <AttributionTable data={data?.utm_medium} title={formatMessage(labels.medium)} /> + </Panel> + <Panel> + <AttributionTable data={data?.utm_cmapaign} title={formatMessage(labels.campaigns)} /> + </Panel> + <Panel> + <AttributionTable data={data?.utm_content} title={formatMessage(labels.content)} /> + </Panel> + <Panel> + <AttributionTable data={data?.utm_term} title={formatMessage(labels.terms)} /> + </Panel> + </Grid> + </Column> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx new file mode 100644 index 0000000..48611c4 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/AttributionPage.tsx @@ -0,0 +1,63 @@ +'use client'; +import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { Attribution } from './Attribution'; + +export function AttributionPage({ websiteId }: { websiteId: string }) { + const [model, setModel] = useState('first-click'); + const [type, setType] = useState('path'); + const [step, setStep] = useState('/'); + const { formatMessage, labels } = useMessages(); + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + + return ( + <Column gap="6"> + <WebsiteControls websiteId={websiteId} /> + <Grid columns={{ xs: '1fr', md: '1fr 1fr 1fr' }} gap> + <Column> + <Select + label={formatMessage(labels.model)} + value={model} + defaultValue={model} + onChange={setModel} + > + <ListItem id="first-click">{formatMessage(labels.firstClick)}</ListItem> + <ListItem id="last-click">{formatMessage(labels.lastClick)}</ListItem> + </Select> + </Column> + <Column> + <Select + label={formatMessage(labels.type)} + value={type} + defaultValue={type} + onChange={setType} + > + <ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem> + <ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem> + </Select> + </Column> + <Column> + <SearchField + label={formatMessage(labels.conversionStep)} + value={step} + defaultValue={step} + onSearch={setStep} + delay={1000} + /> + </Column> + </Grid> + <Attribution + websiteId={websiteId} + startDate={startDate} + endDate={endDate} + model={model} + type={type} + step={step} + /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx new file mode 100644 index 0000000..1368d4b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/attribution/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { AttributionPage } from './AttributionPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <AttributionPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Attribution', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx new file mode 100644 index 0000000..4532d97 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown.tsx @@ -0,0 +1,91 @@ +import { Column, DataColumn, DataTable, Text } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useFields, useFormat, useMessages, useResultQuery } from '@/components/hooks'; +import { formatShortTime } from '@/lib/format'; + +export interface BreakdownProps { + websiteId: string; + startDate: Date; + endDate: Date; + selectedFields: string[]; +} + +export function Breakdown({ websiteId, selectedFields = [], startDate, endDate }: BreakdownProps) { + const { formatMessage, labels } = useMessages(); + const { formatValue } = useFormat(); + const { fields } = useFields(); + const { data, error, isLoading } = useResultQuery<any>( + 'breakdown', + { + websiteId, + startDate, + endDate, + fields: selectedFields, + }, + { enabled: !!selectedFields.length }, + ); + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + <Column overflow="auto" minHeight="0" height="100%"> + <DataTable data={data} style={{ tableLayout: 'fixed' }}> + {selectedFields.map(field => { + return ( + <DataColumn + key={field} + id={field} + label={fields.find(f => f.name === field)?.label} + width="minmax(120px, 1fr)" + > + {row => { + const value = formatValue(row[field], field); + return ( + <Text truncate title={value}> + {value} + </Text> + ); + }} + </DataColumn> + ); + })} + <DataColumn + id="visitors" + label={formatMessage(labels.visitors)} + align="end" + width="120px" + > + {row => row?.visitors?.toLocaleString()} + </DataColumn> + <DataColumn id="visits" label={formatMessage(labels.visits)} align="end" width="120px"> + {row => row?.visits?.toLocaleString()} + </DataColumn> + <DataColumn id="views" label={formatMessage(labels.views)} align="end" width="120px"> + {row => row?.views?.toLocaleString()} + </DataColumn> + <DataColumn + id="bounceRate" + label={formatMessage(labels.bounceRate)} + align="end" + width="120px" + > + {row => { + const n = (Math.min(row?.visits, row?.bounces) / row?.visits) * 100; + return `${Math.round(+n)}%`; + }} + </DataColumn> + <DataColumn + id="visitDuration" + label={formatMessage(labels.visitDuration)} + align="end" + width="120px" + > + {row => { + const n = row?.totaltime / row?.visits; + return `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`; + }} + </DataColumn> + </DataTable> + </Column> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx new file mode 100644 index 0000000..fdead9f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/BreakdownPage.tsx @@ -0,0 +1,51 @@ +'use client'; +import { Column, Row } from '@umami/react-zen'; +import { useState } from 'react'; +import { FieldSelectForm } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { ListCheck } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { Breakdown } from './Breakdown'; + +export function BreakdownPage({ websiteId }: { websiteId: string }) { + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + const [fields, setFields] = useState(['path']); + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <Row alignItems="center" justifyContent="flex-start"> + <FieldsButton value={fields} onChange={setFields} /> + </Row> + <Panel height="900px" overflow="auto" allowFullscreen> + <Breakdown + websiteId={websiteId} + startDate={startDate} + endDate={endDate} + selectedFields={fields} + /> + </Panel> + </Column> + ); +} + +const FieldsButton = ({ value, onChange }) => { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<ListCheck />} + label={formatMessage(labels.fields)} + width="400px" + minHeight="300px" + variant="outline" + > + {({ close }) => { + return <FieldSelectForm selectedFields={value} onChange={onChange} onClose={close} />; + }} + </DialogButton> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx new file mode 100644 index 0000000..28e3368 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/FieldSelectForm.tsx @@ -0,0 +1,46 @@ +import { Button, Column, Grid, List, ListItem } from '@umami/react-zen'; +import { useState } from 'react'; +import { useFields, useMessages } from '@/components/hooks'; + +export function FieldSelectForm({ + selectedFields = [], + onChange, + onClose, +}: { + selectedFields?: string[]; + onChange: (values: string[]) => void; + onClose?: () => void; +}) { + const [selected, setSelected] = useState(selectedFields); + const { formatMessage, labels } = useMessages(); + const { fields } = useFields(); + + const handleChange = (value: string[]) => { + setSelected(value); + }; + + const handleApply = () => { + onChange?.(selected); + onClose(); + }; + + return ( + <Column gap="6"> + <List value={selected} onChange={handleChange} selectionMode="multiple"> + {fields.map(({ name, label }) => { + return ( + <ListItem key={name} id={name}> + {label} + </ListItem> + ); + })} + </List> + <Grid columns="1fr 1fr" gap> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <Button onPress={handleApply} variant="primary"> + {formatMessage(labels.apply)} + </Button> + </Grid> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx new file mode 100644 index 0000000..841d863 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/breakdown/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { BreakdownPage } from './BreakdownPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <BreakdownPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Insights', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx new file mode 100644 index 0000000..e336a3d --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel.tsx @@ -0,0 +1,134 @@ +import { Box, Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useResultQuery } from '@/components/hooks'; +import { File, User } from '@/components/icons'; +import { ReportEditButton } from '@/components/input/ReportEditButton'; +import { ChangeLabel } from '@/components/metrics/ChangeLabel'; +import { Lightning } from '@/components/svg'; +import { formatLongNumber } from '@/lib/format'; +import { FunnelEditForm } from './FunnelEditForm'; + +type FunnelResult = { + type: string; + value: string; + visitors: number; + previous: number; + dropped: number; + dropoff: number; + remaining: number; +}; + +export function Funnel({ id, name, type, parameters, websiteId }) { + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading } = useResultQuery(type, { + websiteId, + ...parameters, + }); + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + <Grid gap> + <Grid columns="1fr auto" gap> + <Column gap> + <Row> + <Text size="4" weight="bold"> + {name} + </Text> + </Row> + </Column> + <Column> + <ReportEditButton id={id} name={name} type={type}> + {({ close }) => { + return ( + <Dialog + title={formatMessage(labels.funnel)} + variant="modal" + style={{ minHeight: 300, minWidth: 400 }} + > + <FunnelEditForm id={id} websiteId={websiteId} onClose={close} /> + </Dialog> + ); + }} + </ReportEditButton> + </Column> + </Grid> + {data?.map( + ( + { type, value, visitors, previous, dropped, dropoff, remaining }: FunnelResult, + index: number, + ) => { + const isPage = type === 'path'; + return ( + <Grid key={index} columns="auto 1fr" gap="6"> + <Column alignItems="center" position="relative"> + <Row + borderRadius="full" + backgroundColor="3" + width="40px" + height="40px" + justifyContent="center" + alignItems="center" + style={{ zIndex: 1 }} + > + <Text weight="bold" size="3"> + {index + 1} + </Text> + </Row> + {index > 0 && ( + <Box + position="absolute" + backgroundColor="3" + width="2px" + height="120px" + top="-100%" + /> + )} + </Column> + <Column gap> + <Row alignItems="center" justifyContent="space-between" gap> + <Text color="muted"> + {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)} + </Text> + <Text color="muted">{formatMessage(labels.conversionRate)}</Text> + </Row> + <Row alignItems="center" justifyContent="space-between" gap> + <Row alignItems="center" gap> + <Icon>{type === 'path' ? <File /> : <Lightning />}</Icon> + <Text>{value}</Text> + </Row> + <Row alignItems="center" gap> + {index > 0 && ( + <ChangeLabel value={-dropped} title={`${-Math.round(dropoff * 100)}%`}> + {formatLongNumber(dropped)} + </ChangeLabel> + )} + <Icon> + <User /> + </Icon> + <Text title={visitors.toString()} transform="lowercase"> + {`${formatLongNumber(visitors)} ${formatMessage(labels.visitors)}`} + </Text> + </Row> + </Row> + <Row alignItems="center" gap="6"> + <ProgressBar + value={visitors || 0} + minValue={0} + maxValue={previous || 1} + style={{ width: '100%' }} + /> + <Row minWidth="90px" justifyContent="end"> + <Text weight="bold" size="7"> + {Math.round(remaining * 100)}% + </Text> + </Row> + </Row> + </Column> + </Grid> + ); + }, + )} + </Grid> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx new file mode 100644 index 0000000..29b5480 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelAddButton.tsx @@ -0,0 +1,28 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { FunnelEditForm } from './FunnelEditForm'; + +export function FunnelAddButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogTrigger> + <Button variant="primary"> + <Icon> + <Plus /> + </Icon> + <Text>{formatMessage(labels.funnel)}</Text> + </Button> + <Modal> + <Dialog + variant="modal" + title={formatMessage(labels.funnel)} + style={{ minHeight: 375, minWidth: 600 }} + > + {({ close }) => <FunnelEditForm websiteId={websiteId} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx new file mode 100644 index 0000000..5d950ea --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelEditForm.tsx @@ -0,0 +1,141 @@ +import { + Button, + Column, + Form, + FormButtons, + FormField, + FormFieldArray, + FormSubmitButton, + Grid, + Icon, + Loading, + Row, + Text, + TextField, +} from '@umami/react-zen'; +import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks'; +import { Plus, X } from '@/components/icons'; +import { ActionSelect } from '@/components/input/ActionSelect'; +import { LookupField } from '@/components/input/LookupField'; + +const FUNNEL_STEPS_MAX = 8; + +export function FunnelEditForm({ + id, + websiteId, + onSave, + onClose, +}: { + id?: string; + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { data } = useReportQuery(id); + const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`); + + const handleSubmit = async ({ name, ...parameters }) => { + await mutateAsync( + { ...data, id, name, type: 'funnel', websiteId, parameters }, + { + onSuccess: async () => { + touch('reports:funnel'); + touch(`report:${id}`); + onSave?.(); + onClose?.(); + }, + }, + ); + }; + + if (id && !data) { + return <Loading placement="absolute" />; + } + + const defaultValues = { + name: data?.name || '', + window: data?.parameters?.window || 60, + steps: data?.parameters?.steps || [{ type: 'path', value: '' }], + }; + + return ( + <Form onSubmit={handleSubmit} error={error?.message} defaultValues={defaultValues}> + <FormField + name="name" + label={formatMessage(labels.name)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoFocus /> + </FormField> + <FormField + name="window" + label={formatMessage(labels.window)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField /> + </FormField> + <FormFieldArray + name="steps" + label={formatMessage(labels.steps)} + rules={{ + validate: value => value.length > 1 || 'At least two steps are required', + }} + > + {({ fields, append, remove }) => { + return ( + <Grid gap> + {fields.map(({ id }: { id: string }, index: number) => { + return ( + <Grid key={id} columns="260px 1fr auto" gap> + <Column> + <FormField + name={`steps.${index}.type`} + rules={{ required: formatMessage(labels.required) }} + > + <ActionSelect /> + </FormField> + </Column> + <Column> + <FormField + name={`steps.${index}.value`} + rules={{ required: formatMessage(labels.required) }} + > + {({ field, context }) => { + const type = context.watch(`steps.${index}.type`); + return <LookupField websiteId={websiteId} type={type} {...field} />; + }} + </FormField> + </Column> + <Button onPress={() => remove(index)}> + <Icon size="sm"> + <X /> + </Icon> + </Button> + </Grid> + ); + })} + <Row> + <Button + onPress={() => append({ type: 'path', value: '' })} + isDisabled={fields.length >= FUNNEL_STEPS_MAX} + > + <Icon> + <Plus /> + </Icon> + <Text>{formatMessage(labels.add)}</Text> + </Button> + </Row> + </Grid> + ); + }} + </FormFieldArray> + <FormButtons> + <Button onPress={onClose} isDisabled={isPending}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx new file mode 100644 index 0000000..57bce52 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/FunnelsPage.tsx @@ -0,0 +1,36 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { SectionHeader } from '@/components/common/SectionHeader'; +import { useDateRange, useReportsQuery } from '@/components/hooks'; +import { Funnel } from './Funnel'; +import { FunnelAddButton } from './FunnelAddButton'; + +export function FunnelsPage({ websiteId }: { websiteId: string }) { + const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'funnel' }); + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <SectionHeader> + <FunnelAddButton websiteId={websiteId} /> + </SectionHeader> + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Grid gap> + {data.data?.map((report: any) => ( + <Panel key={report.id}> + <Funnel {...report} startDate={startDate} endDate={endDate} /> + </Panel> + ))} + </Grid> + )} + </LoadingPanel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx new file mode 100644 index 0000000..2fdcf3b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/funnels/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { FunnelsPage } from './FunnelsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <FunnelsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Funnels', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx new file mode 100644 index 0000000..b6c4a11 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/Goal.tsx @@ -0,0 +1,99 @@ +import { Column, Dialog, Grid, Icon, ProgressBar, Row, Text } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useResultQuery } from '@/components/hooks'; +import { File, User } from '@/components/icons'; +import { ReportEditButton } from '@/components/input/ReportEditButton'; +import { Lightning } from '@/components/svg'; +import { formatLongNumber } from '@/lib/format'; +import { GoalEditForm } from './GoalEditForm'; + +export interface GoalProps { + id: string; + name: string; + type: string; + parameters: { + name: string; + type: string; + value: string; + }; + websiteId: string; + startDate: Date; + endDate: Date; +} + +export type GoalData = { num: number; total: number }; + +export function Goal({ id, name, type, parameters, websiteId, startDate, endDate }: GoalProps) { + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading, isFetching } = useResultQuery<GoalData>(type, { + websiteId, + startDate, + endDate, + ...parameters, + }); + const isPage = parameters?.type === 'path'; + + return ( + <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}> + {data && ( + <Grid gap> + <Grid columns="1fr auto" gap> + <Column gap> + <Row> + <Text size="4" weight="bold"> + {name} + </Text> + </Row> + </Column> + <Column> + <ReportEditButton id={id} name={name} type={type}> + {({ close }) => { + return ( + <Dialog + title={formatMessage(labels.goal)} + variant="modal" + style={{ minHeight: 300, minWidth: 400 }} + > + <GoalEditForm id={id} websiteId={websiteId} onClose={close} /> + </Dialog> + ); + }} + </ReportEditButton> + </Column> + </Grid> + <Row alignItems="center" justifyContent="space-between" gap> + <Text color="muted"> + {formatMessage(isPage ? labels.viewedPage : labels.triggeredEvent)} + </Text> + <Text color="muted">{formatMessage(labels.conversionRate)}</Text> + </Row> + <Row alignItems="center" justifyContent="space-between" gap> + <Row alignItems="center" gap> + <Icon>{parameters.type === 'path' ? <File /> : <Lightning />}</Icon> + <Text>{parameters.value}</Text> + </Row> + <Row alignItems="center" gap> + <Icon> + <User /> + </Icon> + <Text title={`${data?.num} / ${data?.total}`}>{`${formatLongNumber( + data?.num, + )} / ${formatLongNumber(data?.total)}`}</Text> + </Row> + </Row> + <Row alignItems="center" gap="6"> + <ProgressBar + value={data?.num || 0} + minValue={0} + maxValue={data?.total || 1} + style={{ width: '100%' }} + /> + <Text weight="bold" size="7"> + {data?.total ? Math.round((+data?.num / +data?.total) * 100) : '0'}% + </Text> + </Row> + </Grid> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx new file mode 100644 index 0000000..c85b79c --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalAddButton.tsx @@ -0,0 +1,28 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { GoalEditForm } from './GoalEditForm'; + +export function GoalAddButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogTrigger> + <Button variant="primary"> + <Icon> + <Plus /> + </Icon> + <Text>{formatMessage(labels.goal)}</Text> + </Button> + <Modal> + <Dialog + aria-label="add goal" + title={formatMessage(labels.goal)} + style={{ minWidth: 400, minHeight: 300 }} + > + {({ close }) => <GoalEditForm websiteId={websiteId} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx new file mode 100644 index 0000000..7f68047 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalEditForm.tsx @@ -0,0 +1,104 @@ +import { + Button, + Column, + Form, + FormButtons, + FormField, + FormSubmitButton, + Grid, + Label, + Loading, + TextField, +} from '@umami/react-zen'; +import { useMessages, useReportQuery, useUpdateQuery } from '@/components/hooks'; +import { ActionSelect } from '@/components/input/ActionSelect'; +import { LookupField } from '@/components/input/LookupField'; + +export function GoalEditForm({ + id, + websiteId, + onSave, + onClose, +}: { + id?: string; + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { data } = useReportQuery(id); + const { mutateAsync, error, isPending, touch } = useUpdateQuery(`/reports${id ? `/${id}` : ''}`); + + const handleSubmit = async (formData: Record<string, any>) => { + await mutateAsync( + { ...formData, type: 'goal', websiteId }, + { + onSuccess: async () => { + if (id) touch(`report:${id}`); + touch('reports:goal'); + onSave?.(); + onClose?.(); + }, + }, + ); + }; + + if (id && !data) { + return <Loading placement="absolute" />; + } + + const defaultValues = { + name: '', + parameters: { type: 'path', value: '' }, + }; + + return ( + <Form onSubmit={handleSubmit} error={error?.message} defaultValues={data || defaultValues}> + {({ watch }) => { + const type = watch('parameters.type'); + + return ( + <> + <FormField + name="name" + label={formatMessage(labels.name)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoFocus /> + </FormField> + <Column> + <Label>{formatMessage(labels.action)}</Label> + <Grid columns="260px 1fr" gap> + <Column> + <FormField + name="parameters.type" + rules={{ required: formatMessage(labels.required) }} + > + <ActionSelect /> + </FormField> + </Column> + <Column> + <FormField + name="parameters.value" + rules={{ required: formatMessage(labels.required) }} + > + {({ field }) => { + return <LookupField websiteId={websiteId} type={type} {...field} />; + }} + </FormField> + </Column> + </Grid> + </Column> + + <FormButtons> + <Button onPress={onClose} isDisabled={isPending}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton>{formatMessage(labels.save)}</FormSubmitButton> + </FormButtons> + </> + ); + }} + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx new file mode 100644 index 0000000..ff7b49f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/GoalsPage.tsx @@ -0,0 +1,36 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { SectionHeader } from '@/components/common/SectionHeader'; +import { useDateRange, useReportsQuery } from '@/components/hooks'; +import { Goal } from './Goal'; +import { GoalAddButton } from './GoalAddButton'; + +export function GoalsPage({ websiteId }: { websiteId: string }) { + const { data, isLoading, error } = useReportsQuery({ websiteId, type: 'goal' }); + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <SectionHeader> + <GoalAddButton websiteId={websiteId} /> + </SectionHeader> + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> + {data.data.map((report: any) => ( + <Panel key={report.id}> + <Goal {...report} startDate={startDate} endDate={endDate} /> + </Panel> + ))} + </Grid> + )} + </LoadingPanel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx new file mode 100644 index 0000000..b1ab691 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/goals/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { GoalsPage } from './GoalsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <GoalsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Goals', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css new file mode 100644 index 0000000..63643f1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.module.css @@ -0,0 +1,267 @@ +.container { + width: 100%; + height: 100%; + position: relative; + + --journey-line-color: var(--base-color-6); + --journey-active-color: var(--primary-color); + --journey-faded-color: var(--base-color-3); +} + +.view { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + overflow: auto; + gap: 100px; + padding-right: 20px; +} + +.header { + margin-bottom: 20px; +} + +.stats { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 10px; + width: 100%; +} + +.visitors { + font-weight: 600; + font-size: 16px; + text-transform: lowercase; +} + +.dropoff { + font-weight: 600; + color: var(--font-color-muted); + background: var(--base-color-2); + padding: 4px 8px; + border-radius: 5px; +} + +.num { + display: flex; + align-items: center; + justify-content: center; + border-radius: 100%; + width: 50px; + height: 50px; + font-size: 16px; + font-weight: 700; + color: var(--base-color-1); + background: var(--base-color-12); + z-index: 1; + margin: 0 auto 20px; +} + +.column { + display: flex; + flex-direction: column; +} + +.nodes { + position: relative; + display: flex; + flex-direction: column; + height: 100%; +} + +.wrapper { + padding-bottom: 10px; +} + +.node { + position: relative; + cursor: pointer; + padding: 10px 20px; + background: var(--base-color-3); + border-radius: 5px; + display: flex; + align-items: center; + justify-content: space-between; + width: 300px; + max-width: 300px; + height: 60px; + max-height: 60px; +} + +.node:hover:not(.selected) { + background: var(--base-color-4); +} + +.node.selected { + color: var(--base-color-1); + background: var(--base-color-12); +} + +.node.active { + color: var(--primary-font-color); + background: var(--primary-color); +} + +.node.selected .count { + color: var(--base-color-1); + background: var(--base-color-12); +} + +.node.selected.active .count { + color: var(--primary-font-color); + background: var(--primary-color); +} + +.name { + max-width: 200px; +} + +.line { + position: absolute; + bottom: 0; + left: -100px; + width: 100px; + pointer-events: none; +} + +.line.up { + bottom: 0; +} + +.line.down { + top: 0; +} + +.segment { + position: absolute; +} + +.start { + left: 0; + width: 50px; + height: 30px; + border: 0; +} + +.mid { + top: 60px; + width: 50px; + border-right: 3px solid var(--journey-line-color); +} + +.end { + width: 50px; + height: 30px; + border: 0; +} + +.up .start { + top: 30px; + border-top-right-radius: 100%; + border-top: 3px solid var(--journey-line-color); + border-right: 3px solid var(--journey-line-color); +} + +.up .end { + width: 52px; + bottom: 27px; + right: 0; + border-bottom-left-radius: 100%; + border-bottom: 3px solid var(--journey-line-color); + border-left: 3px solid var(--journey-line-color); +} + +.down .start { + bottom: 27px; + border-bottom-right-radius: 100%; + border-bottom: 3px solid var(--journey-line-color); + border-right: 3px solid var(--journey-line-color); +} + +.down .end { + width: 52px; + top: 30px; + right: 0; + border-top-left-radius: 100%; + border-top: 3px solid var(--journey-line-color); + border-left: 3px solid var(--journey-line-color); +} + +.flat .start { + left: 0; + top: 30px; + border-top: 3px solid var(--journey-line-color); +} + +.flat .end { + right: 0; + top: 30px; + border-top: 3px solid var(--journey-line-color); +} + +.start:before, +.end:before { + content: ""; + position: absolute; + border-radius: 100%; + border: 3px solid var(--journey-line-color); + background: var(--base-color-1); + width: 14px; + height: 14px; +} + +.line:not(.active) .start:before, +.line:not(.active) .end:before { + display: none; +} + +.up .start:before { + left: -8px; + top: -8px; +} + +.up .end:before { + right: -8px; + bottom: -8px; +} + +.down .start:before { + left: -8px; + bottom: -8px; +} + +.down .end:before { + right: -8px; + top: -8px; +} + +.flat .start:before { + left: -8px; + top: -8px; +} + +.flat .end:before { + right: -8px; + top: -8px; +} + +.line.active .segment, +.line.active .segment:before { + border-color: var(--journey-active-color); + z-index: 1; +} + +.column.active .line:not(.active) .segment { + border-color: var(--journey-faded-color); +} + +.column.active .line:not(.active) .segment:before { + display: none; +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx new file mode 100644 index 0000000..3327a42 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/Journey.tsx @@ -0,0 +1,294 @@ +import { Column, Focusable, Icon, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import classNames from 'classnames'; +import { useMemo, useState } from 'react'; +import { firstBy } from 'thenby'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useEscapeKey, useMessages, useResultQuery } from '@/components/hooks'; +import { File } from '@/components/icons'; +import { Lightning } from '@/components/svg'; +import { objectToArray } from '@/lib/data'; +import { formatLongNumber } from '@/lib/format'; +import styles from './Journey.module.css'; + +const NODE_HEIGHT = 60; +const NODE_GAP = 10; +const LINE_WIDTH = 3; + +export interface JourneyProps { + websiteId: string; + startDate: Date; + endDate: Date; + steps: number; + startStep?: string; + endStep?: string; +} + +export function Journey({ websiteId, steps, startStep, endStep }: JourneyProps) { + const [selectedNode, setSelectedNode] = useState(null); + const [activeNode, setActiveNode] = useState(null); + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading } = useResultQuery<any>('journey', { + websiteId, + steps, + startStep, + endStep, + }); + + useEscapeKey(() => setSelectedNode(null)); + + const columns = useMemo(() => { + if (!data) { + return []; + } + + const selectedPaths = selectedNode?.paths ?? []; + const activePaths = activeNode?.paths ?? []; + const columns = []; + + for (let columnIndex = 0; columnIndex < +steps; columnIndex++) { + const nodes = {}; + + data.forEach(({ items, count }: any, nodeIndex: any) => { + const name = items[columnIndex]; + + if (name) { + const selected = !!selectedPaths.find(({ items }) => items[columnIndex] === name); + const active = selected && !!activePaths.find(({ items }) => items[columnIndex] === name); + + if (!nodes[name]) { + const paths = data.filter(({ items }) => items[columnIndex] === name); + + nodes[name] = { + name, + count, + totalCount: count, + nodeIndex, + columnIndex, + selected, + active, + paths, + pathMap: paths.map(({ items, count }) => ({ + [`${columnIndex}:${items.join(':')}`]: count, + })), + }; + } else { + nodes[name].totalCount += count; + } + } + }); + + columns.push({ + nodes: objectToArray(nodes).sort(firstBy('total', -1)), + }); + } + + columns.forEach((column, columnIndex) => { + const nodes = column.nodes.map( + ( + currentNode: { totalCount: number; name: string; selected: boolean }, + currentNodeIndex: any, + ) => { + const previousNodes = columns[columnIndex - 1]?.nodes; + let selectedCount = previousNodes ? 0 : currentNode.totalCount; + let activeCount = selectedCount; + + const lines = + previousNodes?.reduce((arr: any[][], previousNode: any, previousNodeIndex: number) => { + const fromCount = selectedNode?.paths.reduce((sum, path) => { + if ( + previousNode.name === path.items[columnIndex - 1] && + currentNode.name === path.items[columnIndex] + ) { + sum += path.count; + } + return sum; + }, 0); + + if (currentNode.selected && previousNode.selected && fromCount) { + arr.push([previousNodeIndex, currentNodeIndex]); + selectedCount += fromCount; + + if (previousNode.active) { + activeCount += fromCount; + } + } + + return arr; + }, []) || []; + + return { ...currentNode, selectedCount, activeCount, lines }; + }, + ); + + const visitorCount = nodes.reduce( + (sum: number, { selected, selectedCount, active, activeCount, totalCount }) => { + if (!selectedNode) { + sum += totalCount; + } else if (!activeNode && selectedNode && selected) { + sum += selectedCount; + } else if (activeNode && active) { + sum += activeCount; + } + return sum; + }, + 0, + ); + + const previousTotal = columns[columnIndex - 1]?.visitorCount ?? 0; + const dropOff = + previousTotal > 0 ? ((visitorCount - previousTotal) / previousTotal) * 100 : 0; + + Object.assign(column, { nodes, visitorCount, dropOff }); + }); + + return columns; + }, [data, selectedNode, activeNode]); + + const handleClick = (name: string, columnIndex: number, paths: any[]) => { + if (name !== selectedNode?.name || columnIndex !== selectedNode?.columnIndex) { + setSelectedNode({ name, columnIndex, paths }); + } else { + setSelectedNode(null); + } + setActiveNode(null); + }; + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error} height="100%"> + <div className={styles.container}> + <div className={styles.view}> + {columns.map(({ visitorCount, nodes }, columnIndex) => { + return ( + <div + key={columnIndex} + className={classNames(styles.column, { + [styles.selected]: selectedNode, + [styles.active]: activeNode, + })} + > + <div className={styles.header}> + <div className={styles.num}>{columnIndex + 1}</div> + <div className={styles.stats}> + <div className={styles.visitors} title={visitorCount}> + {formatLongNumber(visitorCount)} {formatMessage(labels.visitors)} + </div> + </div> + </div> + <div className={styles.nodes}> + {nodes.map( + ({ + name, + totalCount, + selected, + active, + paths, + activeCount, + selectedCount, + lines, + }) => { + const nodeCount = selected + ? active + ? activeCount + : selectedCount + : totalCount; + + const remaining = + columnIndex > 0 + ? Math.round((nodeCount / columns[columnIndex - 1]?.visitorCount) * 100) + : 0; + + const dropped = 100 - remaining; + + return ( + <div + key={name} + className={styles.wrapper} + onMouseEnter={() => + selected && setActiveNode({ name, columnIndex, paths }) + } + onMouseLeave={() => selected && setActiveNode(null)} + > + <div + className={classNames(styles.node, { + [styles.selected]: selected, + [styles.active]: active, + })} + onClick={() => handleClick(name, columnIndex, paths)} + > + <Row alignItems="center" className={styles.name} title={name} gap> + <Icon>{name.startsWith('/') ? <File /> : <Lightning />}</Icon> + <Text truncate>{name}</Text> + </Row> + <div className={styles.count} title={nodeCount}> + <TooltipTrigger + delay={0} + isDisabled={columnIndex === 0 || (selectedNode && !selected)} + > + <Focusable> + <div>{formatLongNumber(nodeCount)}</div> + </Focusable> + <Tooltip placement="top" offset={20} showArrow> + <Text transform="lowercase" color="ruby"> + {`${dropped}% ${formatMessage(labels.dropoff)}`} + </Text> + <Column> + <Text transform="lowercase"> + {`${remaining}% ${formatMessage(labels.conversion)}`} + </Text> + </Column> + </Tooltip> + </TooltipTrigger> + </div> + {columnIndex < columns.length && + lines.map(([fromIndex, nodeIndex], i) => { + const height = + (Math.abs(nodeIndex - fromIndex) + 1) * (NODE_HEIGHT + NODE_GAP) - + NODE_GAP; + const midHeight = + (Math.abs(nodeIndex - fromIndex) - 1) * (NODE_HEIGHT + NODE_GAP) + + NODE_GAP + + LINE_WIDTH; + const nodeName = columns[columnIndex - 1]?.nodes[fromIndex].name; + + return ( + <div + key={`${fromIndex}${nodeIndex}${i}`} + className={classNames(styles.line, { + [styles.active]: + active && + activeNode?.paths.find( + (path: { items: any[] }) => + path.items[columnIndex] === name && + path.items[columnIndex - 1] === nodeName, + ), + [styles.up]: fromIndex < nodeIndex, + [styles.down]: fromIndex > nodeIndex, + [styles.flat]: fromIndex === nodeIndex, + })} + style={{ height }} + > + <div className={classNames(styles.segment, styles.start)} /> + <div + className={classNames(styles.segment, styles.mid)} + style={{ + height: midHeight, + }} + /> + <div className={classNames(styles.segment, styles.end)} /> + </div> + ); + })} + </div> + </div> + ); + }, + )} + </div> + </div> + ); + })} + </div> + </div> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx new file mode 100644 index 0000000..14b8341 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/JourneysPage.tsx @@ -0,0 +1,67 @@ +'use client'; +import { Column, Grid, ListItem, SearchField, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { Journey } from './Journey'; + +const JOURNEY_STEPS = [2, 3, 4, 5, 6, 7]; +const DEFAULT_STEP = 3; + +export function JourneysPage({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + const [steps, setSteps] = useState(DEFAULT_STEP); + const [startStep, setStartStep] = useState(''); + const [endStep, setEndStep] = useState(''); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <Grid columns="repeat(3, 1fr)" gap> + <Select + items={JOURNEY_STEPS} + label={formatMessage(labels.steps)} + value={steps} + defaultValue={steps} + onChange={setSteps} + > + {JOURNEY_STEPS.map(step => ( + <ListItem key={step} id={step}> + {step} + </ListItem> + ))} + </Select> + <Column> + <SearchField + label={formatMessage(labels.startStep)} + value={startStep} + onSearch={setStartStep} + delay={1000} + /> + </Column> + <Column> + <SearchField + label={formatMessage(labels.endStep)} + value={endStep} + onSearch={setEndStep} + delay={1000} + /> + </Column> + </Grid> + <Panel height="900px" allowFullscreen> + <Journey + websiteId={websiteId} + startDate={startDate} + endDate={endDate} + steps={steps} + startStep={startStep} + endStep={endStep} + /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx new file mode 100644 index 0000000..f6062a6 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/journeys/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { JourneysPage } from './JourneysPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <JourneysPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Journeys', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx new file mode 100644 index 0000000..fdd8a14 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/Retention.tsx @@ -0,0 +1,140 @@ +import { Column, Grid, Icon, Row, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { useLocale, useMessages, useResultQuery } from '@/components/hooks'; +import { Users } from '@/components/icons'; +import { formatDate } from '@/lib/date'; +import { formatLongNumber } from '@/lib/format'; + +const DAYS = [1, 2, 3, 4, 5, 6, 7, 14, 21, 28]; + +export interface RetentionProps { + websiteId: string; + startDate: Date; + endDate: Date; + days?: number[]; +} + +export function Retention({ websiteId, days = DAYS, startDate, endDate }: RetentionProps) { + const { formatMessage, labels } = useMessages(); + const { locale } = useLocale(); + const { data, error, isLoading } = useResultQuery('retention', { + websiteId, + startDate, + endDate, + }); + + const rows = + data?.reduce((arr: any[], row: { date: any; visitors: any; day: any }) => { + const { date, visitors, day } = row; + if (day === 0) { + return arr.concat({ + date, + visitors, + records: days + .reduce((arr, day) => { + arr[day] = data.find( + (x: { date: any; day: number }) => x.date === date && x.day === day, + ); + return arr; + }, []) + .filter(n => n), + }); + } + return arr; + }, []) || []; + + const totalDays = rows.length; + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Panel allowFullscreen height="900px"> + <Column + paddingY="6" + paddingX={{ xs: '3', md: '6' }} + position="absolute" + top="40px" + left="0" + right="0" + bottom="0" + > + <Column gap="1" overflow="auto"> + <Grid + columns="120px repeat(10, 100px)" + alignItems="center" + gap="1" + height="50px" + width="max-content" + minWidth="100%" + autoFlow="column" + > + <Column> + <Text weight="bold" align="center"> + {formatMessage(labels.cohort)} + </Text> + </Column> + {days.map(n => ( + <Column key={n}> + <Text weight="bold" align="center" wrap="nowrap"> + {formatMessage(labels.day)} {n} + </Text> + </Column> + ))} + </Grid> + {rows.map(({ date, visitors, records }: any, rowIndex: number) => { + return ( + <Grid + key={rowIndex} + columns="120px repeat(10, 100px)" + gap="1" + autoFlow="column" + width="max-content" + minWidth="100%" + > + <Column justifyContent="center" gap="1"> + <Text weight="bold">{formatDate(date, 'PP', locale)}</Text> + <Row alignItems="center" gap> + <Icon> + <Users /> + </Icon> + <Text>{formatLongNumber(visitors)}</Text> + </Row> + </Column> + {days.map(day => { + if (totalDays - rowIndex < day) { + return null; + } + const percentage = records.filter(a => a.day === day)[0]?.percentage; + return ( + <Cell key={day}> + {percentage ? `${Number(percentage).toFixed(2)}%` : ''} + </Cell> + ); + })} + </Grid> + ); + })} + </Column> + </Column> + </Panel> + )} + </LoadingPanel> + ); +} + +const Cell = ({ children }: { children: ReactNode }) => { + return ( + <Column + justifyContent="center" + alignItems="center" + width="100px" + height="100px" + backgroundColor="2" + borderRadius + > + {children} + </Column> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx new file mode 100644 index 0000000..0ec6e95 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/RetentionPage.tsx @@ -0,0 +1,22 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { endOfMonth, startOfMonth } from 'date-fns'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { useDateRange } from '@/components/hooks'; +import { Retention } from './Retention'; + +export function RetentionPage({ websiteId }: { websiteId: string }) { + const { + dateRange: { startDate }, + } = useDateRange(); + + const monthStartDate = startOfMonth(startDate); + const monthEndDate = endOfMonth(startDate); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} allowDateFilter={false} allowMonthFilter /> + <Retention websiteId={websiteId} startDate={monthStartDate} endDate={monthEndDate} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx new file mode 100644 index 0000000..2fbbc0a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/retention/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { RetentionPage } from './RetentionPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <RetentionPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Retention', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx new file mode 100644 index 0000000..0e782a1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue.tsx @@ -0,0 +1,152 @@ +import { Column, Grid, Row, Text } from '@umami/react-zen'; +import classNames from 'classnames'; +import { colord } from 'colord'; +import { useCallback, useMemo, useState } from 'react'; +import { BarChart } from '@/components/charts/BarChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useCountryNames, useLocale, useMessages, useResultQuery } from '@/components/hooks'; +import { CurrencySelect } from '@/components/input/CurrencySelect'; +import { ListTable } from '@/components/metrics/ListTable'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { renderDateLabels } from '@/lib/charts'; +import { CHART_COLORS } from '@/lib/constants'; +import { generateTimeSeries } from '@/lib/date'; +import { formatLongCurrency, formatLongNumber } from '@/lib/format'; + +export interface RevenueProps { + websiteId: string; + startDate: Date; + endDate: Date; + unit: string; +} + +export function Revenue({ websiteId, startDate, endDate, unit }: RevenueProps) { + const [currency, setCurrency] = useState('USD'); + const { formatMessage, labels } = useMessages(); + const { locale, dateLocale } = useLocale(); + const { countryNames } = useCountryNames(locale); + const { data, error, isLoading } = useResultQuery<any>('revenue', { + websiteId, + startDate, + endDate, + currency, + }); + + const renderCountryName = useCallback( + ({ label: code }) => ( + <Row className={classNames(locale)} gap> + <TypeIcon type="country" value={code} /> + <Text>{countryNames[code] || formatMessage(labels.unknown)}</Text> + </Row> + ), + [countryNames, locale], + ); + + const chartData: any = useMemo(() => { + if (!data) return []; + + const map = (data.chart as any[]).reduce((obj, { x, t, y }) => { + if (!obj[x]) { + obj[x] = []; + } + + obj[x].push({ x: t, y }); + + return obj; + }, {}); + + return { + datasets: Object.keys(map).map((key, index) => { + const color = colord(CHART_COLORS[index % CHART_COLORS.length]); + return { + label: key, + data: generateTimeSeries(map[key], startDate, endDate, unit, dateLocale), + lineTension: 0, + backgroundColor: color.alpha(0.6).toRgbString(), + borderColor: color.alpha(0.7).toRgbString(), + borderWidth: 1, + }; + }), + }; + }, [data, startDate, endDate, unit]); + + const metrics = useMemo(() => { + if (!data) return []; + + const { sum, count, unique_count } = data.total; + + return [ + { + value: sum, + label: formatMessage(labels.total), + formatValue: n => formatLongCurrency(n, currency), + }, + { + value: count ? sum / count : 0, + label: formatMessage(labels.average), + formatValue: n => formatLongCurrency(n, currency), + }, + { + value: count, + label: formatMessage(labels.transactions), + formatValue: formatLongNumber, + }, + { + value: unique_count, + label: formatMessage(labels.uniqueCustomers), + formatValue: formatLongNumber, + }, + ] as any; + }, [data, locale]); + + const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); + + return ( + <Column gap> + <Grid columns="280px" gap> + <CurrencySelect value={currency} onChange={setCurrency} /> + </Grid> + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {data && ( + <Column gap> + <MetricsBar> + {metrics?.map(({ label, value, formatValue }) => { + return ( + <MetricCard key={label} value={value} label={label} formatValue={formatValue} /> + ); + })} + </MetricsBar> + <Panel> + <BarChart + chartData={chartData} + minDate={startDate} + maxDate={endDate} + unit={unit} + stacked={true} + currency={currency} + renderXLabel={renderXLabel} + height="400px" + /> + </Panel> + <Panel> + <ListTable + title={formatMessage(labels.country)} + metric={formatMessage(labels.revenue)} + data={data?.country.map(({ name, value }: { name: string; value: number }) => ({ + label: name, + count: Number(value), + percent: (value / data?.total.sum) * 100, + }))} + currency={currency} + renderLabel={renderCountryName} + /> + </Panel> + </Column> + )} + </LoadingPanel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx new file mode 100644 index 0000000..3e429c1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenuePage.tsx @@ -0,0 +1,18 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { useDateRange } from '@/components/hooks'; +import { Revenue } from './Revenue'; + +export function RevenuePage({ websiteId }: { websiteId: string }) { + const { + dateRange: { startDate, endDate, unit }, + } = useDateRange(); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <Revenue websiteId={websiteId} startDate={startDate} endDate={endDate} unit={unit} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx new file mode 100644 index 0000000..e30d54c --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/RevenueTable.tsx @@ -0,0 +1,21 @@ +import { DataColumn, DataTable } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { formatLongCurrency } from '@/lib/format'; + +export function RevenueTable({ data = [] }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DataTable data={data}> + <DataColumn id="currency" label={formatMessage(labels.currency)} align="end" /> + <DataColumn id="total" label={formatMessage(labels.total)} align="end"> + {(row: any) => formatLongCurrency(row.sum, row.currency)} + </DataColumn> + <DataColumn id="average" label={formatMessage(labels.average)} align="end"> + {(row: any) => formatLongCurrency(row.count ? row.sum / row.count : 0, row.currency)} + </DataColumn> + <DataColumn id="count" label={formatMessage(labels.transactions)} align="end" /> + <DataColumn id="unique_count" label={formatMessage(labels.uniqueCustomers)} align="end" /> + </DataTable> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx new file mode 100644 index 0000000..fba10f1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/revenue/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { RevenuePage } from './RevenuePage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <RevenuePage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Revenue', +}; diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx new file mode 100644 index 0000000..1399174 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTM.tsx @@ -0,0 +1,71 @@ +import { Column, Grid, Heading, Text } from '@umami/react-zen'; +import { PieChart } from '@/components/charts/PieChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Panel } from '@/components/common/Panel'; +import { useMessages, useResultQuery } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { CHART_COLORS, UTM_PARAMS } from '@/lib/constants'; + +export interface UTMProps { + websiteId: string; + startDate: Date; + endDate: Date; +} + +export function UTM({ websiteId, startDate, endDate }: UTMProps) { + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading } = useResultQuery<any>('utm', { + websiteId, + startDate, + endDate, + }); + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error} minHeight="300px"> + {data && ( + <Column gap> + {UTM_PARAMS.map(param => { + const items = data?.[param]; + + const chartData = { + labels: items.map(({ utm }) => utm), + datasets: [ + { + data: items.map(({ views }) => views), + backgroundColor: CHART_COLORS, + borderWidth: 0, + }, + ], + }; + const total = items.reduce((sum, { views }) => { + return +sum + +views; + }, 0); + + return ( + <Panel key={param}> + <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap="6"> + <Column> + <Heading> + <Text transform="capitalize">{param.replace(/^utm_/, '')}</Text> + </Heading> + <ListTable + metric={formatMessage(labels.views)} + data={items.map(({ utm, views }) => ({ + label: utm, + count: views, + percent: (views / total) * 100, + }))} + /> + </Column> + <Column> + <PieChart type="doughnut" chartData={chartData} /> + </Column> + </Grid> + </Panel> + ); + })} + </Column> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx new file mode 100644 index 0000000..0d2a732 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/UTMPage.tsx @@ -0,0 +1,18 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { useDateRange } from '@/components/hooks'; +import { UTM } from './UTM'; + +export function UTMPage({ websiteId }: { websiteId: string }) { + const { + dateRange: { startDate, endDate }, + } = useDateRange(); + + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <UTM websiteId={websiteId} startDate={startDate} endDate={endDate} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx b/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx new file mode 100644 index 0000000..8b8fd6a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/(reports)/utm/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { UTMPage } from './UTMPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <UTMPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'UTM Parameters', +}; diff --git a/src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx b/src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx new file mode 100644 index 0000000..3663812 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/ExpandedViewModal.tsx @@ -0,0 +1,52 @@ +import { Dialog, Modal } from '@umami/react-zen'; +import { WebsiteExpandedView } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedView'; +import { useMobile, useNavigation } from '@/components/hooks'; + +export function ExpandedViewModal({ + websiteId, + excludedIds, +}: { + websiteId: string; + excludedIds?: string[]; +}) { + const { + router, + query: { view }, + updateParams, + } = useNavigation(); + const { isMobile } = useMobile(); + + const handleClose = (close: () => void) => { + router.push(updateParams({ view: undefined })); + close(); + }; + + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + router.push(updateParams({ view: undefined })); + } + }; + + return ( + <Modal isOpen={!!view} onOpenChange={handleOpenChange} isDismissable> + <Dialog + style={{ + maxWidth: 1320, + width: '100vw', + height: isMobile ? '100dvh' : 'calc(100dvh - 40px)', + overflow: 'hidden', + }} + > + {({ close }) => { + return ( + <WebsiteExpandedView + websiteId={websiteId} + excludedIds={excludedIds} + onClose={() => handleClose(close)} + /> + ); + }} + </Dialog> + </Modal> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx new file mode 100644 index 0000000..b2ea2a8 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteChart.tsx @@ -0,0 +1,61 @@ +import { useMemo } from 'react'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useDateRange, useTimezone } from '@/components/hooks'; +import { useWebsitePageviewsQuery } from '@/components/hooks/queries/useWebsitePageviewsQuery'; +import { PageviewsChart } from '@/components/metrics/PageviewsChart'; + +export function WebsiteChart({ + websiteId, + compareMode, +}: { + websiteId: string; + compareMode?: boolean; +}) { + const { timezone } = useTimezone(); + const { dateRange, dateCompare } = useDateRange({ timezone: timezone }); + const { startDate, endDate, unit, value } = dateRange; + const { data, isLoading, isFetching, error } = useWebsitePageviewsQuery({ + websiteId, + compare: compareMode ? dateCompare?.compare : undefined, + }); + const { pageviews, sessions, compare } = (data || {}) as any; + + const chartData = useMemo(() => { + if (data) { + const result = { + pageviews, + sessions, + }; + + if (compare) { + result.compare = { + pageviews: result.pageviews.map(({ x }, i) => ({ + x, + y: compare.pageviews[i]?.y, + d: compare.pageviews[i]?.x, + })), + sessions: result.sessions.map(({ x }, i) => ({ + x, + y: compare.sessions[i]?.y, + d: compare.sessions[i]?.x, + })), + }; + } + + return result; + } + return { pageviews: [], sessions: [] }; + }, [data, startDate, endDate, unit]); + + return ( + <LoadingPanel data={data} isFetching={isFetching} isLoading={isLoading} error={error}> + <PageviewsChart + key={value} + data={chartData} + minDate={startDate} + maxDate={endDate} + unit={unit} + /> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx new file mode 100644 index 0000000..6223dbc --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteControls.tsx @@ -0,0 +1,40 @@ +import { Column, Grid, Row } from '@umami/react-zen'; +import { ExportButton } from '@/components/input/ExportButton'; +import { FilterBar } from '@/components/input/FilterBar'; +import { MonthFilter } from '@/components/input/MonthFilter'; +import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter'; +import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton'; + +export function WebsiteControls({ + websiteId, + allowFilter = true, + allowDateFilter = true, + allowMonthFilter, + allowDownload = false, + allowCompare = false, +}: { + websiteId: string; + allowFilter?: boolean; + allowDateFilter?: boolean; + allowMonthFilter?: boolean; + allowDownload?: boolean; + allowCompare?: boolean; +}) { + return ( + <Column gap> + <Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap> + <Row alignItems="center" justifyContent="flex-start"> + {allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />} + </Row> + <Row alignItems="center" justifyContent={{ xs: 'flex-start', md: 'flex-end' }}> + {allowDateFilter && ( + <WebsiteDateFilter websiteId={websiteId} allowCompare={allowCompare} /> + )} + {allowDownload && <ExportButton websiteId={websiteId} />} + {allowMonthFilter && <MonthFilter />} + </Row> + </Grid> + {allowFilter && <FilterBar websiteId={websiteId} />} + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx new file mode 100644 index 0000000..29c3954 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedMenu.tsx @@ -0,0 +1,183 @@ +import { SideMenu } from '@/components/common/SideMenu'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { + AppWindow, + Cpu, + Earth, + Globe, + Landmark, + Languages, + Laptop, + LogIn, + LogOut, + MapPin, + Megaphone, + Monitor, + Network, + Search, + Share2, + SquareSlash, + Tag, + Type, +} from '@/components/icons'; +import { Lightning } from '@/components/svg'; + +export function WebsiteExpandedMenu({ + excludedIds = [], + onItemClick, +}: { + excludedIds?: string[]; + onItemClick?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { + updateParams, + query: { view }, + } = useNavigation(); + + const filterExcluded = (item: { id: string }) => !excludedIds.includes(item.id); + + const items = [ + { + label: 'URL', + items: [ + { + id: 'path', + label: formatMessage(labels.path), + path: updateParams({ view: 'path' }), + icon: <SquareSlash />, + }, + { + id: 'entry', + label: formatMessage(labels.entry), + path: updateParams({ view: 'entry' }), + icon: <LogIn />, + }, + { + id: 'exit', + label: formatMessage(labels.exit), + path: updateParams({ view: 'exit' }), + icon: <LogOut />, + }, + { + id: 'title', + label: formatMessage(labels.title), + path: updateParams({ view: 'title' }), + icon: <Type />, + }, + { + id: 'query', + label: formatMessage(labels.query), + path: updateParams({ view: 'query' }), + icon: <Search />, + }, + ].filter(filterExcluded), + }, + { + label: formatMessage(labels.sources), + items: [ + { + id: 'referrer', + label: formatMessage(labels.referrer), + path: updateParams({ view: 'referrer' }), + icon: <Share2 />, + }, + { + id: 'channel', + label: formatMessage(labels.channel), + path: updateParams({ view: 'channel' }), + icon: <Megaphone />, + }, + { + id: 'domain', + label: formatMessage(labels.domain), + path: updateParams({ view: 'domain' }), + icon: <Globe />, + }, + ].filter(filterExcluded), + }, + { + label: formatMessage(labels.location), + items: [ + { + id: 'country', + label: formatMessage(labels.country), + path: updateParams({ view: 'country' }), + icon: <Earth />, + }, + { + id: 'region', + label: formatMessage(labels.region), + path: updateParams({ view: 'region' }), + icon: <MapPin />, + }, + { + id: 'city', + label: formatMessage(labels.city), + path: updateParams({ view: 'city' }), + icon: <Landmark />, + }, + ].filter(filterExcluded), + }, + { + label: formatMessage(labels.environment), + items: [ + { + id: 'browser', + label: formatMessage(labels.browser), + path: updateParams({ view: 'browser' }), + icon: <AppWindow />, + }, + { + id: 'os', + label: formatMessage(labels.os), + path: updateParams({ view: 'os' }), + icon: <Cpu />, + }, + { + id: 'device', + label: formatMessage(labels.device), + path: updateParams({ view: 'device' }), + icon: <Laptop />, + }, + { + id: 'language', + label: formatMessage(labels.language), + path: updateParams({ view: 'language' }), + icon: <Languages />, + }, + { + id: 'screen', + label: formatMessage(labels.screen), + path: updateParams({ view: 'screen' }), + icon: <Monitor />, + }, + ].filter(filterExcluded), + }, + { + label: formatMessage(labels.other), + items: [ + { + id: 'event', + label: formatMessage(labels.event), + path: updateParams({ view: 'event' }), + icon: <Lightning />, + }, + { + id: 'hostname', + label: formatMessage(labels.hostname), + path: updateParams({ view: 'hostname' }), + icon: <Network />, + }, + { + id: 'tag', + label: formatMessage(labels.tag), + path: updateParams({ view: 'tag' }), + icon: <Tag />, + }, + ].filter(filterExcluded), + }, + ]; + + return <SideMenu items={items} selectedKey={view} onItemClick={onItemClick} />; +} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx new file mode 100644 index 0000000..2c670df --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteExpandedView.tsx @@ -0,0 +1,57 @@ +import { Column, Grid, Row } from '@umami/react-zen'; +import { WebsiteExpandedMenu } from '@/app/(main)/websites/[websiteId]/WebsiteExpandedMenu'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { MobileMenuButton } from '@/components/input/MobileMenuButton'; +import { MetricsExpandedTable } from '@/components/metrics/MetricsExpandedTable'; + +export function WebsiteExpandedView({ + websiteId, + excludedIds = [], + onClose, +}: { + websiteId: string; + excludedIds?: string[]; + onClose?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { + query: { view }, + } = useNavigation(); + + return ( + <Column height="100%" overflow="hidden" gap> + <Row id="expanded-mobile-menu-button" display={{ xs: 'flex', md: 'none' }}> + <MobileMenuButton> + {({ close }) => { + return ( + <Column padding="3"> + <WebsiteExpandedMenu excludedIds={excludedIds} onItemClick={close} /> + </Column> + ); + }} + </MobileMenuButton> + </Row> + <Grid columns={{ xs: '1fr', md: 'auto 1fr' }} gap="6" overflow="hidden"> + <Column + id="metrics-expanded-menu" + display={{ xs: 'none', md: 'flex' }} + width="240px" + gap="6" + border="right" + paddingRight="3" + overflow="auto" + > + <WebsiteExpandedMenu excludedIds={excludedIds} /> + </Column> + <Column id="metrics-expanded-table" overflow="hidden"> + <MetricsExpandedTable + title={formatMessage(labels[view])} + type={view} + websiteId={websiteId} + onClose={onClose} + /> + </Column> + </Grid> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx new file mode 100644 index 0000000..7dd1d77 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteHeader.tsx @@ -0,0 +1,57 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import { WebsiteShareForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm'; +import { Favicon } from '@/components/common/Favicon'; +import { LinkButton } from '@/components/common/LinkButton'; +import { PageHeader } from '@/components/common/PageHeader'; +import { useMessages, useNavigation, useWebsite } from '@/components/hooks'; +import { Edit, Share } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { ActiveUsers } from '@/components/metrics/ActiveUsers'; + +export function WebsiteHeader({ showActions }: { showActions?: boolean }) { + const website = useWebsite(); + const { renderUrl, pathname } = useNavigation(); + const isSettings = pathname.endsWith('/settings'); + + const { formatMessage, labels } = useMessages(); + + if (isSettings) { + return null; + } + + return ( + <PageHeader + title={website.name} + icon={<Favicon domain={website.domain} />} + titleHref={renderUrl(`/websites/${website.id}`, false)} + > + <Row alignItems="center" gap="6" wrap="wrap"> + <ActiveUsers websiteId={website.id} /> + + {showActions && ( + <Row alignItems="center" gap> + <ShareButton websiteId={website.id} shareId={website.shareId} /> + <LinkButton href={renderUrl(`/websites/${website.id}/settings`, false)}> + <Icon> + <Edit /> + </Icon> + <Text>{formatMessage(labels.edit)}</Text> + </LinkButton> + </Row> + )} + </Row> + </PageHeader> + ); +} + +const ShareButton = ({ websiteId, shareId }) => { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton icon={<Share />} label={formatMessage(labels.share)} width="800px"> + {({ close }) => { + return <WebsiteShareForm websiteId={websiteId} shareId={shareId} onClose={close} />; + }} + </DialogButton> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx b/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx new file mode 100644 index 0000000..7260a7e --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteLayout.tsx @@ -0,0 +1,30 @@ +'use client'; +import { Column, Grid } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider'; +import { PageBody } from '@/components/common/PageBody'; +import { WebsiteHeader } from './WebsiteHeader'; +import { WebsiteNav } from './WebsiteNav'; + +export function WebsiteLayout({ websiteId, children }: { websiteId: string; children: ReactNode }) { + return ( + <WebsiteProvider websiteId={websiteId}> + <Grid columns={{ xs: '1fr', lg: 'auto 1fr' }} width="100%" height="100%"> + <Column + display={{ xs: 'none', lg: 'flex' }} + width="240px" + height="100%" + border="right" + backgroundColor + marginRight="2" + > + <WebsiteNav websiteId={websiteId} /> + </Column> + <PageBody gap> + <WebsiteHeader showActions /> + <Column>{children}</Column> + </PageBody> + </Grid> + </WebsiteProvider> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx new file mode 100644 index 0000000..3018953 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteMenu.tsx @@ -0,0 +1,56 @@ +import { + Button, + Icon, + Menu, + MenuItem, + MenuSeparator, + MenuTrigger, + Popover, + Text, +} from '@umami/react-zen'; +import { Fragment } from 'react'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { Edit, More, Share } from '@/components/icons'; + +export function WebsiteMenu({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { router, updateParams, renderUrl } = useNavigation(); + + const menuItems = [ + { id: 'share', label: formatMessage(labels.share), icon: <Share /> }, + { id: 'edit', label: formatMessage(labels.edit), icon: <Edit />, seperator: true }, + ]; + + const handleAction = (id: any) => { + if (id === 'compare') { + router.push(updateParams({ compare: 'prev' })); + } else if (id === 'edit') { + router.push(renderUrl(`/websites/${websiteId}`)); + } + }; + + return ( + <MenuTrigger> + <Button variant="quiet"> + <Icon> + <More /> + </Icon> + </Button> + <Popover placement="bottom"> + <Menu onAction={handleAction}> + {menuItems.map(({ id, label, icon, seperator }, index) => { + return ( + <Fragment key={index}> + {seperator && <MenuSeparator />} + <MenuItem id={id}> + <Icon>{icon}</Icon> + <Text>{label}</Text> + </MenuItem> + </Fragment> + ); + })} + </Menu> + </Popover> + </MenuTrigger> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx new file mode 100644 index 0000000..6c91ba6 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteMetricsBar.tsx @@ -0,0 +1,88 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { useWebsiteStatsQuery } from '@/components/hooks/queries/useWebsiteStatsQuery'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { formatLongNumber, formatShortTime } from '@/lib/format'; + +export function WebsiteMetricsBar({ + websiteId, +}: { + websiteId: string; + showChange?: boolean; + compareMode?: boolean; +}) { + const { isAllTime } = useDateRange(); + const { formatMessage, labels, getErrorMessage } = useMessages(); + const { data, isLoading, isFetching, error } = useWebsiteStatsQuery(websiteId); + + const { pageviews, visitors, visits, bounces, totaltime, 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, + }, + { + label: formatMessage(labels.bounceRate), + value: (Math.min(visits, bounces) / visits) * 100, + prev: (Math.min(comparison.visits, comparison.bounces) / comparison.visits) * 100, + change: + (Math.min(visits, bounces) / visits) * 100 - + (Math.min(comparison.visits, comparison.bounces) / comparison.visits) * 100, + formatValue: n => `${Math.round(+n)}%`, + reverseColors: true, + }, + { + label: formatMessage(labels.visitDuration), + value: totaltime / visits, + prev: comparison.totaltime / comparison.visits, + change: totaltime / visits - comparison.totaltime / comparison.visits, + formatValue: n => + `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`, + }, + ] + : null; + + return ( + <LoadingPanel + data={metrics} + isLoading={isLoading} + isFetching={isFetching} + error={getErrorMessage(error)} + minHeight="136px" + > + <MetricsBar> + {metrics?.map(({ label, value, prev, change, formatValue, reverseColors }) => { + return ( + <MetricCard + key={label} + value={value} + previousValue={prev} + label={label} + change={change} + formatValue={formatValue} + reverseColors={reverseColors} + showChange={!isAllTime} + /> + ); + })} + </MetricsBar> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx new file mode 100644 index 0000000..ad05b70 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteNav.tsx @@ -0,0 +1,180 @@ +import { Column, Text } from '@umami/react-zen'; +import { SideMenu } from '@/components/common/SideMenu'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { + AlignEndHorizontal, + ChartPie, + Clock, + Eye, + Sheet, + Tag, + User, + UserPlus, +} from '@/components/icons'; +import { WebsiteSelect } from '@/components/input/WebsiteSelect'; +import { Funnel, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg'; + +export function WebsiteNav({ + websiteId, + onItemClick, +}: { + websiteId: string; + onItemClick?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { pathname, renderUrl, teamId, router } = useNavigation(); + + const renderPath = (path: string) => + renderUrl(`/websites/${websiteId}${path}`, { + event: undefined, + compare: undefined, + view: undefined, + }); + + const items = [ + { + label: formatMessage(labels.traffic), + items: [ + { + id: 'overview', + label: formatMessage(labels.overview), + icon: <Eye />, + path: renderPath(''), + }, + { + id: 'events', + label: formatMessage(labels.events), + icon: <Lightning />, + path: renderPath('/events'), + }, + { + id: 'sessions', + label: formatMessage(labels.sessions), + icon: <User />, + path: renderPath('/sessions'), + }, + { + id: 'realtime', + label: formatMessage(labels.realtime), + icon: <Clock />, + path: renderPath('/realtime'), + }, + { + id: 'compare', + label: formatMessage(labels.compare), + icon: <AlignEndHorizontal />, + path: renderPath('/compare'), + }, + { + id: 'breakdown', + label: formatMessage(labels.breakdown), + icon: <Sheet />, + path: renderPath('/breakdown'), + }, + ], + }, + { + label: formatMessage(labels.behavior), + items: [ + { + id: 'goals', + label: formatMessage(labels.goals), + icon: <Target />, + path: renderPath('/goals'), + }, + { + id: 'funnel', + label: formatMessage(labels.funnels), + icon: <Funnel />, + path: renderPath('/funnels'), + }, + { + id: 'journeys', + label: formatMessage(labels.journeys), + icon: <Path />, + path: renderPath('/journeys'), + }, + { + id: 'retention', + label: formatMessage(labels.retention), + icon: <Magnet />, + path: renderPath('/retention'), + }, + ], + }, + { + label: formatMessage(labels.audience), + items: [ + { + id: 'segments', + label: formatMessage(labels.segments), + icon: <ChartPie />, + path: renderPath('/segments'), + }, + { + id: 'cohorts', + label: formatMessage(labels.cohorts), + icon: <UserPlus />, + path: renderPath('/cohorts'), + }, + ], + }, + { + label: formatMessage(labels.growth), + items: [ + { + id: 'utm', + label: formatMessage(labels.utm), + icon: <Tag />, + path: renderPath('/utm'), + }, + { + id: 'revenue', + label: formatMessage(labels.revenue), + icon: <Money />, + path: renderPath('/revenue'), + }, + { + id: 'attribution', + label: formatMessage(labels.attribution), + icon: <Network />, + path: renderPath('/attribution'), + }, + ], + }, + ]; + + const handleChange = (value: string) => { + router.push(renderUrl(`/websites/${value}`)); + }; + + const renderValue = (value: any) => { + return ( + <Text truncate style={{ maxWidth: 160, lineHeight: 1 }}> + {value?.selectedItem?.name} + </Text> + ); + }; + + const selectedKey = items + .flatMap(e => e.items) + .find(({ path }) => path && pathname.endsWith(path.split('?')[0]))?.id; + + return ( + <Column padding="3" position="sticky" top="0" gap> + <WebsiteSelect + websiteId={websiteId} + teamId={teamId} + onChange={handleChange} + renderValue={renderValue} + buttonProps={{ style: { outline: 'none' } }} + /> + <SideMenu + items={items} + selectedKey={selectedKey} + allowMinimize={false} + onItemClick={onItemClick} + /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsitePage.tsx b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx new file mode 100644 index 0000000..f587e11 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsitePage.tsx @@ -0,0 +1,22 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { ExpandedViewModal } from '@/app/(main)/websites/[websiteId]/ExpandedViewModal'; +import { Panel } from '@/components/common/Panel'; +import { WebsiteChart } from './WebsiteChart'; +import { WebsiteControls } from './WebsiteControls'; +import { WebsiteMetricsBar } from './WebsiteMetricsBar'; +import { WebsitePanels } from './WebsitePanels'; + +export function WebsitePage({ websiteId }: { websiteId: string }) { + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} /> + <WebsiteMetricsBar websiteId={websiteId} showChange={true} /> + <Panel minHeight="520px"> + <WebsiteChart websiteId={websiteId} /> + </Panel> + <WebsitePanels websiteId={websiteId} /> + <ExpandedViewModal websiteId={websiteId} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx b/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx new file mode 100644 index 0000000..a91d562 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsitePanels.tsx @@ -0,0 +1,140 @@ +import { Grid, Heading, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { GridRow } from '@/components/common/GridRow'; +import { Panel } from '@/components/common/Panel'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { EventsChart } from '@/components/metrics/EventsChart'; +import { MetricsTable } from '@/components/metrics/MetricsTable'; +import { WeeklyTraffic } from '@/components/metrics/WeeklyTraffic'; +import { WorldMap } from '@/components/metrics/WorldMap'; + +export function WebsitePanels({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { pathname } = useNavigation(); + const tableProps = { + websiteId, + limit: 10, + allowDownload: false, + showMore: true, + metric: formatMessage(labels.visitors), + }; + const rowProps = { minHeight: '570px' }; + const isSharePage = pathname.includes('/share/'); + + return ( + <Grid gap="3"> + <GridRow layout="two" {...rowProps}> + <Panel> + <Heading size="2">{formatMessage(labels.pages)}</Heading> + <Tabs> + <TabList> + <Tab id="path">{formatMessage(labels.path)}</Tab> + <Tab id="entry">{formatMessage(labels.entry)}</Tab> + <Tab id="exit">{formatMessage(labels.exit)}</Tab> + </TabList> + <TabPanel id="path"> + <MetricsTable type="path" title={formatMessage(labels.path)} {...tableProps} /> + </TabPanel> + <TabPanel id="entry"> + <MetricsTable type="entry" title={formatMessage(labels.path)} {...tableProps} /> + </TabPanel> + <TabPanel id="exit"> + <MetricsTable type="exit" title={formatMessage(labels.path)} {...tableProps} /> + </TabPanel> + </Tabs> + </Panel> + <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.referrer)} + {...tableProps} + /> + </TabPanel> + <TabPanel id="channel"> + <MetricsTable type="channel" title={formatMessage(labels.channel)} {...tableProps} /> + </TabPanel> + </Tabs> + </Panel> + </GridRow> + + <GridRow layout="two" {...rowProps}> + <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> + + <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> + + <GridRow layout="two-one" {...rowProps}> + <Panel gridColumn={{ xs: 'span 1', md: 'span 2' }} paddingX="0" paddingY="0"> + <WorldMap websiteId={websiteId} /> + </Panel> + + <Panel> + <Heading size="2">{formatMessage(labels.traffic)}</Heading> + <Row border="bottom" marginBottom="4" /> + <WeeklyTraffic websiteId={websiteId} /> + </Panel> + </GridRow> + {isSharePage && ( + <GridRow layout="two-one" {...rowProps}> + <Panel> + <Heading size="2">{formatMessage(labels.events)}</Heading> + <Row border="bottom" marginBottom="4" /> + <MetricsTable + websiteId={websiteId} + type="event" + title={formatMessage(labels.event)} + metric={formatMessage(labels.count)} + limit={15} + filterLink={false} + /> + </Panel> + <Panel gridColumn={{ xs: 'span 1', md: 'span 2' }}> + <EventsChart websiteId={websiteId} /> + </Panel> + </GridRow> + )} + </Grid> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx b/src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx new file mode 100644 index 0000000..ac978a2 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/WebsiteTabs.tsx @@ -0,0 +1,64 @@ +import { Icon, Row, Tab, TabList, Tabs, Text } from '@umami/react-zen'; +import { useMessages, useNavigation, useWebsite } from '@/components/hooks'; +import { ChartPie, Clock, Eye, User } from '@/components/icons'; +import { Lightning } from '@/components/svg'; + +export function WebsiteTabs() { + const website = useWebsite(); + const { pathname, renderUrl } = useNavigation(); + const { formatMessage, labels } = useMessages(); + + const links = [ + { + id: 'overview', + label: formatMessage(labels.overview), + icon: <Eye />, + path: '', + }, + { + id: 'events', + label: formatMessage(labels.events), + icon: <Lightning />, + path: '/events', + }, + { + id: 'sessions', + label: formatMessage(labels.sessions), + icon: <User />, + path: '/sessions', + }, + { + id: 'realtime', + label: formatMessage(labels.realtime), + icon: <Clock />, + path: '/realtime', + }, + { + id: 'reports', + label: formatMessage(labels.reports), + icon: <ChartPie />, + path: '/reports', + }, + ]; + + const selectedKey = links.find(({ path }) => path && pathname.includes(path))?.id || 'overview'; + + return ( + <Row marginBottom="6"> + <Tabs selectedKey={selectedKey}> + <TabList> + {links.map(({ id, label, icon, path }) => { + return ( + <Tab key={id} id={id} href={renderUrl(`/websites/${website.id}${path}`)}> + <Row alignItems="center" gap> + <Icon>{icon}</Icon> + <Text>{label}</Text> + </Row> + </Tab> + ); + })} + </TabList> + </Tabs> + </Row> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx new file mode 100644 index 0000000..3f7f872 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortAddButton.tsx @@ -0,0 +1,21 @@ +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { CohortEditForm } from './CohortEditForm'; + +export function CohortAddButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Plus />} + label={formatMessage(labels.cohort)} + variant="primary" + width="800px" + > + {({ close }) => { + return <CohortEditForm websiteId={websiteId} onClose={close} />; + }} + </DialogButton> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx new file mode 100644 index 0000000..94d62ff --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton.tsx @@ -0,0 +1,60 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { messages } from '@/components/messages'; + +export function CohortDeleteButton({ + cohortId, + websiteId, + name, + onSave, +}: { + cohortId: string; + websiteId: string; + name: string; + onSave?: () => void; +}) { + const { formatMessage, labels, FormattedMessage } = useMessages(); + const { mutateAsync, isPending, error, touch } = useDeleteQuery( + `/websites/${websiteId}/segments/${cohortId}`, + ); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: () => { + touch('cohorts'); + onSave?.(); + close(); + }, + }); + }; + + return ( + <DialogButton + icon={<Trash />} + variant="quiet" + title={formatMessage(labels.confirm)} + width="400px" + > + {({ close }) => ( + <ConfirmationForm + message={ + <FormattedMessage + {...messages.confirmRemove} + values={{ + target: <b>{name}</b>, + }} + /> + } + isLoading={isPending} + error={error} + onConfirm={handleConfirm.bind(null, close)} + onClose={close} + buttonLabel={formatMessage(labels.delete)} + buttonVariant="danger" + /> + )} + </DialogButton> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx new file mode 100644 index 0000000..0799071 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditButton.tsx @@ -0,0 +1,37 @@ +import { CohortEditForm } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditForm'; +import { useMessages } from '@/components/hooks'; +import { Edit } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import type { Filter } from '@/lib/types'; + +export function CohortEditButton({ + cohortId, + websiteId, + filters, +}: { + cohortId: string; + websiteId: string; + filters: Filter[]; +}) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Edit />} + variant="quiet" + title={formatMessage(labels.cohort)} + width="800px" + > + {({ close }) => { + return ( + <CohortEditForm + cohortId={cohortId} + websiteId={websiteId} + filters={filters} + onClose={close} + /> + ); + }} + </DialogButton> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx new file mode 100644 index 0000000..c755035 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortEditForm.tsx @@ -0,0 +1,135 @@ +import { + Button, + Column, + Form, + FormButtons, + FormField, + FormSubmitButton, + Grid, + Label, + Loading, + TextField, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery, useWebsiteCohortQuery } from '@/components/hooks'; +import { ActionSelect } from '@/components/input/ActionSelect'; +import { DateFilter } from '@/components/input/DateFilter'; +import { FieldFilters } from '@/components/input/FieldFilters'; +import { LookupField } from '@/components/input/LookupField'; + +export function CohortEditForm({ + cohortId, + websiteId, + filters = [], + onSave, + onClose, +}: { + cohortId?: string; + websiteId: string; + filters?: any[]; + showFilters?: boolean; + onSave?: () => void; + onClose?: () => void; +}) { + const { data } = useWebsiteCohortQuery(websiteId, cohortId); + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + + const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery( + `/websites/${websiteId}/segments${cohortId ? `/${cohortId}` : ''}`, + { + type: 'cohort', + }, + ); + + const handleSubmit = async (formData: any) => { + await mutateAsync(formData, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch('cohorts'); + onSave?.(); + onClose?.(); + }, + }); + }; + + if (cohortId && !data) { + return <Loading placement="absolute" />; + } + + const defaultValues = { + parameters: { filters, dateRange: '30day', action: { type: 'path', value: '' } }, + }; + + return ( + <Form + error={getErrorMessage(error)} + onSubmit={handleSubmit} + defaultValues={data || defaultValues} + > + {({ watch }) => { + const type = watch('parameters.action.type'); + + return ( + <> + <FormField + name="name" + label={formatMessage(labels.name)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoFocus /> + </FormField> + + <Column> + <Label>{formatMessage(labels.action)}</Label> + <Grid columns={{ xs: '1fr', md: '1fr 1fr' }} gap> + <Column> + <FormField + name="parameters.action.type" + rules={{ required: formatMessage(labels.required) }} + > + <ActionSelect /> + </FormField> + </Column> + <Column> + <FormField + name="parameters.action.value" + rules={{ required: formatMessage(labels.required) }} + > + {({ field }) => { + return <LookupField websiteId={websiteId} type={type} {...field} />; + }} + </FormField> + </Column> + </Grid> + </Column> + + <Column width="260px"> + <Label>{formatMessage(labels.dateRange)}</Label> + <FormField + name="parameters.dateRange" + rules={{ required: formatMessage(labels.required) }} + > + <DateFilter placement="bottom start" /> + </FormField> + </Column> + + <Column> + <Label>{formatMessage(labels.filters)}</Label> + <FormField name="parameters.filters"> + <FieldFilters websiteId={websiteId} exclude={['path', 'event']} /> + </FormField> + </Column> + + <FormButtons> + <Button isDisabled={isPending} onPress={onClose}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + </> + ); + }} + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx new file mode 100644 index 0000000..6734384 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsDataTable.tsx @@ -0,0 +1,24 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useWebsiteCohortsQuery } from '@/components/hooks'; +import { CohortAddButton } from './CohortAddButton'; +import { CohortsTable } from './CohortsTable'; + +export function CohortsDataTable({ websiteId }: { websiteId?: string }) { + const query = useWebsiteCohortsQuery(websiteId, { type: 'cohort' }); + + const renderActions = () => { + return <CohortAddButton websiteId={websiteId} />; + }; + + return ( + <DataGrid + query={query} + allowSearch={true} + autoFocus={false} + allowPaging={true} + renderActions={renderActions} + > + {({ data }) => <CohortsTable data={data} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx new file mode 100644 index 0000000..14f366e --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsPage.tsx @@ -0,0 +1,16 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { CohortsDataTable } from './CohortsDataTable'; + +export function CohortsPage({ websiteId }) { + return ( + <Column gap="3"> + <WebsiteControls websiteId={websiteId} allowFilter={false} allowDateFilter={false} /> + <Panel> + <CohortsDataTable websiteId={websiteId} /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx b/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx new file mode 100644 index 0000000..5c7ac03 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/CohortsTable.tsx @@ -0,0 +1,41 @@ +import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen'; +import Link from 'next/link'; +import { CohortDeleteButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortDeleteButton'; +import { CohortEditButton } from '@/app/(main)/websites/[websiteId]/cohorts/CohortEditButton'; +import { DateDistance } from '@/components/common/DateDistance'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { filtersObjectToArray } from '@/lib/params'; + +export function CohortsTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { websiteId, renderUrl } = useNavigation(); + + return ( + <DataTable {...props}> + <DataColumn id="name" label={formatMessage(labels.name)}> + {(row: any) => ( + <Link href={renderUrl(`/websites/${websiteId}?cohort=${row.id}`, false)}>{row.name}</Link> + )} + </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, parameters } = row; + + return ( + <Row> + <CohortEditButton + cohortId={id} + websiteId={websiteId} + filters={filtersObjectToArray(parameters)} + /> + <CohortDeleteButton cohortId={id} websiteId={websiteId} name={name} /> + </Row> + ); + }} + </DataColumn> + </DataTable> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/cohorts/page.tsx b/src/app/(main)/websites/[websiteId]/cohorts/page.tsx new file mode 100644 index 0000000..9946f60 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/cohorts/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { CohortsPage } from './CohortsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <CohortsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Cohorts', +}; diff --git a/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx new file mode 100644 index 0000000..bca8d24 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/compare/ComparePage.tsx @@ -0,0 +1,20 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar'; +import { Panel } from '@/components/common/Panel'; +import { CompareTables } from './CompareTables'; + +export function ComparePage({ websiteId }: { websiteId: string }) { + return ( + <Column gap> + <WebsiteControls websiteId={websiteId} allowCompare={true} /> + <WebsiteMetricsBar websiteId={websiteId} showChange={true} /> + <Panel minHeight="520px"> + <WebsiteChart websiteId={websiteId} compareMode={true} /> + </Panel> + <CompareTables websiteId={websiteId} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx new file mode 100644 index 0000000..13c0516 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/compare/CompareTables.tsx @@ -0,0 +1,171 @@ +import { Column, Grid, Heading, ListItem, Row, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { DateDisplay } from '@/components/common/DateDisplay'; +import { Panel } from '@/components/common/Panel'; +import { useDateRange, useMessages, useNavigation } from '@/components/hooks'; +import { ChangeLabel } from '@/components/metrics/ChangeLabel'; +import { MetricsTable } from '@/components/metrics/MetricsTable'; +import { formatNumber } from '@/lib/format'; + +export function CompareTables({ websiteId }: { websiteId: string }) { + const [data, setData] = useState([]); + const { dateRange, dateCompare } = useDateRange(); + const { formatMessage, labels } = useMessages(); + const { + router, + updateParams, + query: { view = 'path' }, + } = useNavigation(); + const { startDate, endDate } = dateCompare; + + const params = { + startAt: startDate.getTime(), + endAt: endDate.getTime(), + }; + + const renderPath = (view: string) => { + return updateParams({ view }); + }; + + const items = [ + { + id: 'path', + label: formatMessage(labels.path), + path: renderPath('path'), + }, + { + id: 'channel', + label: formatMessage(labels.channels), + path: renderPath('channel'), + }, + { + id: 'referrer', + label: formatMessage(labels.referrers), + path: renderPath('referrer'), + }, + { + id: 'browser', + label: formatMessage(labels.browsers), + path: renderPath('browser'), + }, + { + id: 'os', + label: formatMessage(labels.os), + path: renderPath('os'), + }, + { + id: 'device', + label: formatMessage(labels.devices), + path: renderPath('device'), + }, + { + id: 'country', + label: formatMessage(labels.countries), + path: renderPath('country'), + }, + { + id: 'region', + label: formatMessage(labels.regions), + path: renderPath('region'), + }, + { + id: 'city', + label: formatMessage(labels.cities), + path: renderPath('city'), + }, + { + id: 'language', + label: formatMessage(labels.languages), + path: renderPath('language'), + }, + { + id: 'screen', + label: formatMessage(labels.screens), + path: renderPath('screen'), + }, + { + id: 'event', + label: formatMessage(labels.events), + path: renderPath('event'), + }, + { + id: 'hostname', + label: formatMessage(labels.hostname), + path: renderPath('hostname'), + }, + { + id: 'tag', + label: formatMessage(labels.tags), + path: renderPath('tag'), + }, + ]; + + const renderChange = ({ label, count }) => { + const prev = data.find(d => d.x === label)?.y; + const value = count - prev; + const change = Math.abs(((count - prev) / prev) * 100); + + return ( + !Number.isNaN(change) && ( + <Row alignItems="center" marginRight="3"> + <ChangeLabel value={value}>{formatNumber(change)}%</ChangeLabel> + </Row> + ) + ); + }; + + const handleChange = (id: any) => { + router.push(renderPath(id)); + }; + + return ( + <> + <Row width="300px"> + <Select + items={items} + label={formatMessage(labels.compare)} + value={view} + defaultValue={view} + onChange={handleChange} + > + {items.map(({ id, label }) => ( + <ListItem key={id} id={id}> + {label} + </ListItem> + ))} + </Select> + </Row> + <Panel minHeight="300px"> + <Grid columns={{ xs: '1fr', lg: '1fr 1fr' }} gap="6" height="100%"> + <Column gap="6"> + <Row alignItems="center" justifyContent="space-between"> + <Heading size="2">{formatMessage(labels.previous)}</Heading> + <DateDisplay startDate={startDate} endDate={endDate} /> + </Row> + <MetricsTable + websiteId={websiteId} + type={view} + limit={20} + showMore={false} + params={params} + onDataLoad={setData} + /> + </Column> + <Column border="left" paddingLeft="6" gap="6"> + <Row alignItems="center" justifyContent="space-between"> + <Heading size="2"> {formatMessage(labels.current)}</Heading> + <DateDisplay startDate={dateRange.startDate} endDate={dateRange.endDate} /> + </Row> + <MetricsTable + websiteId={websiteId} + type={view} + limit={20} + showMore={false} + renderChange={renderChange} + /> + </Column> + </Grid> + </Panel> + </> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/compare/page.tsx b/src/app/(main)/websites/[websiteId]/compare/page.tsx new file mode 100644 index 0000000..1b2899b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/compare/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { ComparePage } from './ComparePage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <ComparePage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Compare', +}; diff --git a/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx new file mode 100644 index 0000000..c3b1325 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/EventProperties.tsx @@ -0,0 +1,127 @@ +import { Column, Grid, ListItem, Select } from '@umami/react-zen'; +import { useMemo, useState } from 'react'; +import { PieChart } from '@/components/charts/PieChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { + useEventDataPropertiesQuery, + useEventDataValuesQuery, + useMessages, +} from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { CHART_COLORS } from '@/lib/constants'; + +export function EventProperties({ websiteId }: { websiteId: string }) { + const [propertyName, setPropertyName] = useState(''); + const [eventName, setEventName] = useState(''); + + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useEventDataPropertiesQuery(websiteId); + + const events: string[] = data + ? data.reduce((arr: string | any[], e: { eventName: any }) => { + return !arr.includes(e.eventName) ? arr.concat(e.eventName) : arr; + }, []) + : []; + const properties: string[] = eventName + ? data?.filter(e => e.eventName === eventName).map(e => e.propertyName) + : []; + + return ( + <LoadingPanel + data={data} + isLoading={isLoading} + isFetching={isFetching} + error={error} + minHeight="300px" + > + <Column gap="6"> + {data && ( + <Grid columns="repeat(auto-fill, minmax(300px, 1fr))" marginBottom="3" gap> + <Select + label={formatMessage(labels.event)} + value={eventName} + onChange={setEventName} + placeholder="" + > + {events?.map(p => ( + <ListItem key={p} id={p}> + {p} + </ListItem> + ))} + </Select> + <Select + label={formatMessage(labels.property)} + value={propertyName} + onChange={setPropertyName} + isDisabled={!eventName} + placeholder="" + > + {properties?.map(p => ( + <ListItem key={p} id={p}> + {p} + </ListItem> + ))} + </Select> + </Grid> + )} + {eventName && propertyName && ( + <EventValues websiteId={websiteId} eventName={eventName} propertyName={propertyName} /> + )} + </Column> + </LoadingPanel> + ); +} + +const EventValues = ({ websiteId, eventName, propertyName }) => { + const { + data: values, + isLoading, + isFetching, + error, + } = useEventDataValuesQuery(websiteId, eventName, propertyName); + + const propertySum = useMemo(() => { + return values?.reduce((sum, { total }) => sum + total, 0) ?? 0; + }, [values]); + + const chartData = useMemo(() => { + if (!propertyName || !values) return null; + return { + labels: values.map(({ value }) => value), + datasets: [ + { + data: values.map(({ total }) => total), + backgroundColor: CHART_COLORS, + borderWidth: 0, + }, + ], + }; + }, [propertyName, values]); + + const tableData = useMemo(() => { + if (!propertyName || !values || propertySum === 0) return []; + return values.map(({ value, total }) => ({ + label: value, + count: total, + percent: 100 * (total / propertySum), + })); + }, [propertyName, values, propertySum]); + + return ( + <LoadingPanel + isLoading={isLoading} + isFetching={isFetching} + data={values} + error={error} + minHeight="300px" + gap="6" + > + {values && ( + <Grid columns="1fr 1fr" gap> + <ListTable title={propertyName} data={tableData} /> + <PieChart type="doughnut" chartData={chartData} /> + </Grid> + )} + </LoadingPanel> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx new file mode 100644 index 0000000..f686b3f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/EventsDataTable.tsx @@ -0,0 +1,48 @@ +import { type ReactNode, useState } from 'react'; +import { DataGrid } from '@/components/common/DataGrid'; +import { useMessages, useWebsiteEventsQuery } from '@/components/hooks'; +import { FilterButtons } from '@/components/input/FilterButtons'; +import { EventsTable } from './EventsTable'; + +export function EventsDataTable({ + websiteId, +}: { + websiteId?: string; + teamId?: string; + children?: ReactNode; +}) { + const { formatMessage, labels } = useMessages(); + const [view, setView] = useState('all'); + const query = useWebsiteEventsQuery(websiteId, { view }); + + const buttons = [ + { + id: 'all', + label: formatMessage(labels.all), + }, + { + id: 'views', + label: formatMessage(labels.views), + }, + { + id: 'events', + label: formatMessage(labels.events), + }, + ]; + + const renderActions = () => { + return <FilterButtons items={buttons} value={view} onChange={setView} />; + }; + + return ( + <DataGrid + query={query} + allowSearch={true} + autoFocus={false} + allowPaging={true} + renderActions={renderActions} + > + {({ data }) => <EventsTable data={data} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx new file mode 100644 index 0000000..a7ed399 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/EventsMetricsBar.tsx @@ -0,0 +1,40 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages } from '@/components/hooks'; +import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { formatLongNumber } from '@/lib/format'; + +export function EventsMetricsBar({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId); + + return ( + <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}> + {data && ( + <MetricsBar> + <MetricCard + value={data?.visitors?.value} + label={formatMessage(labels.visitors)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.visits?.value} + label={formatMessage(labels.visits)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.pageviews?.value} + label={formatMessage(labels.views)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.events?.value} + label={formatMessage(labels.events)} + formatValue={formatLongNumber} + /> + </MetricsBar> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx new file mode 100644 index 0000000..55ec040 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/EventsPage.tsx @@ -0,0 +1,59 @@ +'use client'; +import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { type Key, useState } from 'react'; +import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { EventsChart } from '@/components/metrics/EventsChart'; +import { MetricsTable } from '@/components/metrics/MetricsTable'; +import { getItem, setItem } from '@/lib/storage'; +import { EventProperties } from './EventProperties'; +import { EventsDataTable } from './EventsDataTable'; + +const KEY_NAME = 'umami.events.tab'; + +export function EventsPage({ websiteId }) { + const [tab, setTab] = useState(getItem(KEY_NAME) || 'chart'); + const { formatMessage, labels } = useMessages(); + + const handleSelect = (value: Key) => { + setItem(KEY_NAME, value); + setTab(value); + }; + + return ( + <Column gap="3"> + <WebsiteControls websiteId={websiteId} /> + <Panel> + <Tabs selectedKey={tab} onSelectionChange={key => handleSelect(key)}> + <TabList> + <Tab id="chart">{formatMessage(labels.chart)}</Tab> + <Tab id="activity">{formatMessage(labels.activity)}</Tab> + <Tab id="properties">{formatMessage(labels.properties)}</Tab> + </TabList> + <TabPanel id="activity"> + <EventsDataTable websiteId={websiteId} /> + </TabPanel> + <TabPanel id="chart"> + <Column gap="6"> + <Column border="bottom" paddingBottom="6"> + <EventsChart websiteId={websiteId} /> + </Column> + <MetricsTable + websiteId={websiteId} + type="event" + title={formatMessage(labels.event)} + metric={formatMessage(labels.count)} + /> + </Column> + </TabPanel> + <TabPanel id="properties"> + <EventProperties websiteId={websiteId} /> + </TabPanel> + </Tabs> + </Panel> + <SessionModal websiteId={websiteId} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx new file mode 100644 index 0000000..7fb2eb4 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/EventsTable.tsx @@ -0,0 +1,107 @@ +import { + Button, + DataColumn, + DataTable, + type DataTableProps, + Dialog, + DialogTrigger, + Icon, + IconLabel, + Popover, + Row, + Text, +} from '@umami/react-zen'; +import Link from 'next/link'; +import { Avatar } from '@/components/common/Avatar'; +import { DateDistance } from '@/components/common/DateDistance'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useFormat, useMessages, useNavigation } from '@/components/hooks'; +import { Eye, FileText } from '@/components/icons'; +import { EventData } from '@/components/metrics/EventData'; +import { Lightning } from '@/components/svg'; + +export function EventsTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { updateParams } = useNavigation(); + const { formatValue } = useFormat(); + + return ( + <DataTable {...props}> + <DataColumn id="event" label={formatMessage(labels.event)} width="2fr"> + {(row: any) => { + return ( + <Row alignItems="center" wrap="wrap" gap> + <Row> + <IconLabel + icon={row.eventName ? <Lightning /> : <Eye />} + label={formatMessage(row.eventName ? labels.triggeredEvent : labels.viewedPage)} + /> + </Row> + <Text + weight="bold" + style={{ maxWidth: '300px' }} + title={row.eventName || row.urlPath} + truncate + > + {row.eventName || row.urlPath} + </Text> + {row.hasData > 0 && <PropertiesButton websiteId={row.websiteId} eventId={row.id} />} + </Row> + ); + }} + </DataColumn> + <DataColumn id="session" label={formatMessage(labels.session)} width="80px"> + {(row: any) => { + return ( + <Link href={updateParams({ session: row.sessionId })}> + <Avatar seed={row.sessionId} size={32} /> + </Link> + ); + }} + </DataColumn> + <DataColumn id="location" label={formatMessage(labels.location)}> + {(row: any) => ( + <TypeIcon type="country" value={row.country}> + {row.city ? `${row.city}, ` : ''} {formatValue(row.country, 'country')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="browser" label={formatMessage(labels.browser)} width="140px"> + {(row: any) => ( + <TypeIcon type="browser" value={row.browser}> + {formatValue(row.browser, 'browser')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="device" label={formatMessage(labels.device)} width="120px"> + {(row: any) => ( + <TypeIcon type="device" value={row.device}> + {formatValue(row.device, 'device')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="created" width="160px" align="end"> + {(row: any) => <DateDistance date={new Date(row.createdAt)} />} + </DataColumn> + </DataTable> + ); +} + +const PropertiesButton = props => { + return ( + <DialogTrigger> + <Button variant="quiet"> + <Row alignItems="center" gap> + <Icon> + <FileText /> + </Icon> + </Row> + </Button> + <Popover placement="right"> + <Dialog> + <EventData {...props} /> + </Dialog> + </Popover> + </DialogTrigger> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/events/page.tsx b/src/app/(main)/websites/[websiteId]/events/page.tsx new file mode 100644 index 0000000..d77ba3b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/events/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { EventsPage } from './EventsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <EventsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Events', +}; diff --git a/src/app/(main)/websites/[websiteId]/layout.tsx b/src/app/(main)/websites/[websiteId]/layout.tsx new file mode 100644 index 0000000..67595e9 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/layout.tsx @@ -0,0 +1,21 @@ +import type { Metadata } from 'next'; +import { WebsiteLayout } from '@/app/(main)/websites/[websiteId]/WebsiteLayout'; + +export default async function ({ + children, + params, +}: { + children: any; + params: Promise<{ websiteId: string }>; +}) { + const { websiteId } = await params; + + return <WebsiteLayout websiteId={websiteId}>{children}</WebsiteLayout>; +} + +export const metadata: Metadata = { + title: { + template: '%s | Umami', + default: 'Websites | Umami', + }, +}; diff --git a/src/app/(main)/websites/[websiteId]/page.tsx b/src/app/(main)/websites/[websiteId]/page.tsx new file mode 100644 index 0000000..d4889c5 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { WebsitePage } from './WebsitePage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <WebsitePage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Websites', +}; diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx new file mode 100644 index 0000000..6e2495b --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeCountries.tsx @@ -0,0 +1,31 @@ +import { IconLabel } from '@umami/react-zen'; +import { useCallback } from 'react'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useCountryNames, useLocale, useMessages } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; + +export function RealtimeCountries({ data }) { + const { formatMessage, labels } = useMessages(); + const { locale } = useLocale(); + const { countryNames } = useCountryNames(locale); + + const renderCountryName = useCallback( + ({ label: code }) => ( + <IconLabel icon={<TypeIcon type="country" value={code} />} label={countryNames[code]} /> + ), + [countryNames, locale], + ); + + return ( + <ListTable + title={formatMessage(labels.countries)} + metric={formatMessage(labels.visitors)} + data={data.map(({ x, y, z }: { x: string; y: number; z: number }) => ({ + label: x, + count: y, + percent: z, + }))} + renderLabel={renderCountryName} + /> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx new file mode 100644 index 0000000..2b9d881 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeHeader.tsx @@ -0,0 +1,17 @@ +import { useMessages } from '@/components/hooks'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; + +export function RealtimeHeader({ data }: { data: any }) { + const { formatMessage, labels } = useMessages(); + const { totals }: any = data || {}; + + return ( + <MetricsBar> + <MetricCard label={formatMessage(labels.views)} value={totals.views} /> + <MetricCard label={formatMessage(labels.visitors)} value={totals.visitors} /> + <MetricCard label={formatMessage(labels.events)} value={totals.events} /> + <MetricCard label={formatMessage(labels.countries)} value={totals.countries} /> + </MetricsBar> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx new file mode 100644 index 0000000..1076361 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeLog.tsx @@ -0,0 +1,206 @@ +import { Column, Heading, IconLabel, Row, SearchField, Text } from '@umami/react-zen'; +import Link from 'next/link'; +import { useMemo, useState } from 'react'; +import { FixedSizeList } from 'react-window'; +import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; +import { useFormat } from '@/components//hooks/useFormat'; +import { Avatar } from '@/components/common/Avatar'; +import { Empty } from '@/components/common/Empty'; +import { + useCountryNames, + useLocale, + useMessages, + useMobile, + useNavigation, + useTimezone, + useWebsite, +} from '@/components/hooks'; +import { Eye, User } from '@/components/icons'; +import { FilterButtons } from '@/components/input/FilterButtons'; +import { Lightning } from '@/components/svg'; +import { BROWSERS, OS_NAMES } from '@/lib/constants'; + +const TYPE_ALL = 'all'; +const TYPE_PAGEVIEW = 'pageview'; +const TYPE_SESSION = 'session'; +const TYPE_EVENT = 'event'; + +const icons = { + [TYPE_PAGEVIEW]: <Eye />, + [TYPE_SESSION]: <User />, + [TYPE_EVENT]: <Lightning />, +}; + +export function RealtimeLog({ data }: { data: any }) { + const website = useWebsite(); + const [search, setSearch] = useState(''); + const { formatMessage, labels, messages, FormattedMessage } = useMessages(); + const { formatValue } = useFormat(); + const { locale } = useLocale(); + const { formatTimezoneDate } = useTimezone(); + const { countryNames } = useCountryNames(locale); + const [filter, setFilter] = useState(TYPE_ALL); + const { updateParams } = useNavigation(); + const { isPhone } = useMobile(); + + const buttons = [ + { + label: formatMessage(labels.all), + id: TYPE_ALL, + }, + { + label: formatMessage(labels.views), + id: TYPE_PAGEVIEW, + }, + { + label: formatMessage(labels.visitors), + id: TYPE_SESSION, + }, + { + label: formatMessage(labels.events), + id: TYPE_EVENT, + }, + ]; + + const getTime = ({ createdAt, firstAt }) => formatTimezoneDate(firstAt || createdAt, 'pp'); + + const getIcon = ({ __type }) => icons[__type]; + + const getDetail = (log: { + __type: string; + eventName: string; + urlPath: string; + browser: string; + os: string; + country: string; + device: string; + }) => { + const { __type, eventName, urlPath, browser, os, country, device } = log; + + if (__type === TYPE_EVENT) { + return ( + <FormattedMessage + {...messages.eventLog} + values={{ + event: <b key="b">{eventName || formatMessage(labels.unknown)}</b>, + url: ( + <a + key="a" + href={`//${website?.domain}${urlPath}`} + target="_blank" + rel="noreferrer noopener" + > + {urlPath} + </a> + ), + }} + /> + ); + } + + if (__type === TYPE_PAGEVIEW) { + return ( + <a href={`//${website?.domain}${urlPath}`} target="_blank" rel="noreferrer noopener"> + {urlPath} + </a> + ); + } + + if (__type === TYPE_SESSION) { + return ( + <FormattedMessage + {...messages.visitorLog} + values={{ + country: <b key="country">{countryNames[country] || formatMessage(labels.unknown)}</b>, + browser: <b key="browser">{BROWSERS[browser]}</b>, + os: <b key="os">{OS_NAMES[os] || os}</b>, + device: <b key="device">{formatMessage(labels[device] || labels.unknown)}</b>, + }} + /> + ); + } + }; + + const TableRow = ({ index, style }) => { + const row = logs[index]; + return ( + <Row alignItems="center" style={style} gap> + <Row minWidth="30px"> + <Link href={updateParams({ session: row.sessionId })}> + <Avatar seed={row.sessionId} size={32} /> + </Link> + </Row> + <Row minWidth="100px"> + <Text wrap="nowrap">{getTime(row)}</Text> + </Row> + <IconLabel icon={getIcon(row)}> + <Text style={{ maxWidth: isPhone ? '400px' : null }} truncate> + {getDetail(row)} + </Text> + </IconLabel> + </Row> + ); + }; + + const logs = useMemo(() => { + if (!data) { + return []; + } + + let logs = data.events; + + if (search) { + logs = logs.filter(({ eventName, urlPath, browser, os, country, device }) => { + return [ + eventName, + urlPath, + os, + formatValue(browser, 'browser'), + formatValue(country, 'country'), + formatValue(device, 'device'), + ] + .filter(n => n) + .map(n => n.toLowerCase()) + .join('') + .includes(search.toLowerCase()); + }); + } + + if (filter !== TYPE_ALL) { + return logs.filter(({ __type }) => __type === filter); + } + + return logs; + }, [data, filter, formatValue, search]); + + return ( + <Column gap> + <Heading size="2">{formatMessage(labels.activity)}</Heading> + {isPhone ? ( + <> + <Row> + <SearchField value={search} onSearch={setSearch} /> + </Row> + <Row> + <FilterButtons items={buttons} value={filter} onChange={setFilter} /> + </Row> + </> + ) : ( + <Row alignItems="center" justifyContent="space-between"> + <SearchField value={search} onSearch={setSearch} /> + <FilterButtons items={buttons} value={filter} onChange={setFilter} /> + </Row> + )} + + <Column> + {logs?.length === 0 && <Empty />} + {logs?.length > 0 && ( + <FixedSizeList width="100%" height={500} itemCount={logs.length} itemSize={50}> + {TableRow} + </FixedSizeList> + )} + </Column> + <SessionModal websiteId={website.id} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx new file mode 100644 index 0000000..6220c69 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePage.tsx @@ -0,0 +1,58 @@ +'use client'; +import { Grid } from '@umami/react-zen'; +import { firstBy } from 'thenby'; +import { GridRow } from '@/components/common/GridRow'; +import { PageBody } from '@/components/common/PageBody'; +import { Panel } from '@/components/common/Panel'; +import { useMobile, useRealtimeQuery } from '@/components/hooks'; +import { RealtimeChart } from '@/components/metrics/RealtimeChart'; +import { WorldMap } from '@/components/metrics/WorldMap'; +import { percentFilter } from '@/lib/filters'; +import { RealtimeCountries } from './RealtimeCountries'; +import { RealtimeHeader } from './RealtimeHeader'; +import { RealtimeLog } from './RealtimeLog'; +import { RealtimePaths } from './RealtimePaths'; +import { RealtimeReferrers } from './RealtimeReferrers'; + +export function RealtimePage({ websiteId }: { websiteId: string }) { + const { data, isLoading, error } = useRealtimeQuery(websiteId); + const { isMobile } = useMobile(); + + if (isLoading || error) { + return <PageBody isLoading={isLoading} error={error} />; + } + + const countries = percentFilter( + Object.keys(data.countries) + .map(key => ({ x: key, y: data.countries[key] })) + .sort(firstBy('y', -1)), + ); + + return ( + <Grid gap="3"> + <RealtimeHeader data={data} /> + <Panel> + <RealtimeChart data={data} unit="minute" /> + </Panel> + <Panel> + <RealtimeLog data={data} /> + </Panel> + <GridRow layout="two"> + <Panel> + <RealtimePaths data={data} /> + </Panel> + <Panel> + <RealtimeReferrers data={data} /> + </Panel> + </GridRow> + <GridRow layout="one-two"> + <Panel> + <RealtimeCountries data={countries} /> + </Panel> + <Panel gridColumn={isMobile ? null : 'span 2'} padding="0"> + <WorldMap data={countries} /> + </Panel> + </GridRow> + </Grid> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx new file mode 100644 index 0000000..1f90ad8 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimePaths.tsx @@ -0,0 +1,45 @@ +import thenby from 'thenby'; +import { useMessages, useWebsite } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { percentFilter } from '@/lib/filters'; + +export function RealtimePaths({ data }: { data: any }) { + const website = useWebsite(); + const { formatMessage, labels } = useMessages(); + const { urls } = data || {}; + const limit = 15; + + const renderLink = ({ label: x }) => { + const domain = x.startsWith('/') ? website?.domain : ''; + return ( + <a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener"> + {x} + </a> + ); + }; + + const pages = percentFilter( + Object.keys(urls) + .map(key => { + return { + x: key, + y: urls[key], + }; + }) + .sort(thenby.firstBy('y', -1)) + .slice(0, limit), + ); + + return ( + <ListTable + title={formatMessage(labels.pages)} + metric={formatMessage(labels.views)} + renderLabel={renderLink} + data={pages.map(({ x, y, z }: { x: string; y: number; z: number }) => ({ + label: x, + count: y, + percent: z, + }))} + /> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx new file mode 100644 index 0000000..9fd4477 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/RealtimeReferrers.tsx @@ -0,0 +1,45 @@ +import thenby from 'thenby'; +import { useMessages, useWebsite } from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { percentFilter } from '@/lib/filters'; + +export function RealtimeReferrers({ data }: { data: any }) { + const website = useWebsite(); + const { formatMessage, labels } = useMessages(); + const { referrers } = data || {}; + const limit = 15; + + const renderLink = ({ label: x }) => { + const domain = x.startsWith('/') ? website?.domain : ''; + return ( + <a href={`//${domain}${x}`} target="_blank" rel="noreferrer noopener"> + {x} + </a> + ); + }; + + const domains = percentFilter( + Object.keys(referrers) + .map(key => { + return { + x: key, + y: referrers[key], + }; + }) + .sort(thenby.firstBy('y', -1)) + .slice(0, limit), + ); + + return ( + <ListTable + title={formatMessage(labels.referrers)} + metric={formatMessage(labels.views)} + renderLabel={renderLink} + data={domains.map(({ x, y, z }: { x: string; y: number; z: number }) => ({ + label: x, + count: y, + percent: z, + }))} + /> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/realtime/page.tsx b/src/app/(main)/websites/[websiteId]/realtime/page.tsx new file mode 100644 index 0000000..1552196 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/realtime/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { RealtimePage } from './RealtimePage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <RealtimePage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Real-time', +}; diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx new file mode 100644 index 0000000..7b70fee --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/segments/SegmentAddButton.tsx @@ -0,0 +1,21 @@ +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { SegmentEditForm } from './SegmentEditForm'; + +export function SegmentAddButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Plus />} + label={formatMessage(labels.segment)} + variant="primary" + width="800px" + > + {({ close }) => { + return <SegmentEditForm websiteId={websiteId} onClose={close} />; + }} + </DialogButton> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx new file mode 100644 index 0000000..bb52a22 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton.tsx @@ -0,0 +1,60 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { messages } from '@/components/messages'; + +export function SegmentDeleteButton({ + segmentId, + websiteId, + name, + onSave, +}: { + segmentId: string; + websiteId: string; + name: string; + onSave?: () => void; +}) { + const { formatMessage, labels, FormattedMessage } = useMessages(); + const { mutateAsync, isPending, error, touch } = useDeleteQuery( + `/websites/${websiteId}/segments/${segmentId}`, + ); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: () => { + touch('segments'); + onSave?.(); + close(); + }, + }); + }; + + return ( + <DialogButton + icon={<Trash />} + title={formatMessage(labels.confirm)} + variant="quiet" + width="600px" + > + {({ close }) => ( + <ConfirmationForm + message={ + <FormattedMessage + {...messages.confirmRemove} + values={{ + target: <b>{name}</b>, + }} + /> + } + isLoading={isPending} + error={error} + onConfirm={handleConfirm.bind(null, close)} + onClose={close} + buttonLabel={formatMessage(labels.delete)} + buttonVariant="danger" + /> + )} + </DialogButton> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx new file mode 100644 index 0000000..5c56cf1 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/segments/SegmentEditButton.tsx @@ -0,0 +1,37 @@ +import { useMessages } from '@/components/hooks'; +import { Edit } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import type { Filter } from '@/lib/types'; +import { SegmentEditForm } from './SegmentEditForm'; + +export function SegmentEditButton({ + segmentId, + websiteId, + filters, +}: { + segmentId: string; + websiteId: string; + filters?: Filter[]; +}) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogButton + icon={<Edit />} + title={formatMessage(labels.segment)} + variant="quiet" + width="800px" + > + {({ close }) => { + return ( + <SegmentEditForm + segmentId={segmentId} + websiteId={websiteId} + filters={filters} + onClose={close} + /> + ); + }} + </DialogButton> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx new file mode 100644 index 0000000..c3529d9 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/segments/SegmentEditForm.tsx @@ -0,0 +1,86 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + Label, + Loading, + TextField, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery, useWebsiteSegmentQuery } from '@/components/hooks'; +import { FieldFilters } from '@/components/input/FieldFilters'; +import { messages } from '@/components/messages'; + +export function SegmentEditForm({ + segmentId, + websiteId, + filters = [], + showFilters = true, + onSave, + onClose, +}: { + segmentId?: string; + websiteId: string; + filters?: any[]; + showFilters?: boolean; + onSave?: () => void; + onClose?: () => void; +}) { + const { data } = useWebsiteSegmentQuery(websiteId, segmentId); + const { formatMessage, labels, getErrorMessage } = useMessages(); + + const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery( + `/websites/${websiteId}/segments${segmentId ? `/${segmentId}` : ''}`, + { + type: 'segment', + }, + ); + + const handleSubmit = async (formData: any) => { + await mutateAsync(formData, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch('segments'); + onSave?.(); + onClose?.(); + }, + }); + }; + + if (segmentId && !data) { + return <Loading placement="absolute" />; + } + + return ( + <Form + onSubmit={handleSubmit} + defaultValues={data || { parameters: { filters } }} + error={getErrorMessage(error)} + > + <FormField + name="name" + label={formatMessage(labels.name)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoFocus={!segmentId} /> + </FormField> + {showFilters && ( + <> + <Label>{formatMessage(labels.filters)}</Label> + <FormField name="parameters.filters" rules={{ required: formatMessage(labels.required) }}> + <FieldFilters websiteId={websiteId} /> + </FormField> + </> + )} + <FormButtons> + <Button isDisabled={isPending} onPress={onClose}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton variant="primary" data-test="button-submit" isDisabled={isPending}> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx new file mode 100644 index 0000000..c1ba82e --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsDataTable.tsx @@ -0,0 +1,24 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useWebsiteSegmentsQuery } from '@/components/hooks'; +import { SegmentAddButton } from './SegmentAddButton'; +import { SegmentsTable } from './SegmentsTable'; + +export function SegmentsDataTable({ websiteId }: { websiteId?: string }) { + const query = useWebsiteSegmentsQuery(websiteId, { type: 'segment' }); + + const renderActions = () => { + return <SegmentAddButton websiteId={websiteId} />; + }; + + return ( + <DataGrid + query={query} + allowSearch={true} + autoFocus={false} + allowPaging={true} + renderActions={renderActions} + > + {({ data }) => <SegmentsTable data={data} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx new file mode 100644 index 0000000..cbe7a1c --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsPage.tsx @@ -0,0 +1,16 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { SegmentsDataTable } from './SegmentsDataTable'; + +export function SegmentsPage({ websiteId }) { + return ( + <Column gap="3"> + <WebsiteControls websiteId={websiteId} allowFilter={false} allowDateFilter={false} /> + <Panel> + <SegmentsDataTable websiteId={websiteId} /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx b/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx new file mode 100644 index 0000000..4dbe511 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/segments/SegmentsTable.tsx @@ -0,0 +1,38 @@ +import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen'; +import Link from 'next/link'; +import { SegmentDeleteButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentDeleteButton'; +import { SegmentEditButton } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditButton'; +import { DateDistance } from '@/components/common/DateDistance'; +import { useMessages, useNavigation } from '@/components/hooks'; + +export function SegmentsTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { websiteId, renderUrl } = useNavigation(); + + return ( + <DataTable {...props}> + <DataColumn id="name" label={formatMessage(labels.name)}> + {(row: any) => ( + <Link href={renderUrl(`/websites/${websiteId}?segment=${row.id}`, false)}> + {row.name} + </Link> + )} + </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> + <SegmentEditButton segmentId={id} websiteId={websiteId} /> + <SegmentDeleteButton segmentId={id} websiteId={websiteId} name={name} /> + </Row> + ); + }} + </DataColumn> + </DataTable> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/segments/page.tsx b/src/app/(main)/websites/[websiteId]/segments/page.tsx new file mode 100644 index 0000000..0d3faac --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/segments/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { SegmentsPage } from './SegmentsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <SegmentsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Segments', +}; diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx new file mode 100644 index 0000000..cbb2810 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionActivity.tsx @@ -0,0 +1,94 @@ +import { + Button, + Column, + Dialog, + DialogTrigger, + Heading, + Icon, + Popover, + Row, + StatusLight, + Text, +} from '@umami/react-zen'; +import { isSameDay } from 'date-fns'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useMobile, useSessionActivityQuery, useTimezone } from '@/components/hooks'; +import { Eye, FileText } from '@/components/icons'; +import { EventData } from '@/components/metrics/EventData'; +import { Lightning } from '@/components/svg'; + +export function SessionActivity({ + websiteId, + sessionId, + startDate, + endDate, +}: { + websiteId: string; + sessionId: string; + startDate: Date; + endDate: Date; +}) { + const { formatMessage, labels } = useMessages(); + const { formatTimezoneDate } = useTimezone(); + const { data, isLoading, error } = useSessionActivityQuery( + websiteId, + sessionId, + startDate, + endDate, + ); + const { isMobile } = useMobile(); + let lastDay = null; + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + <Column gap> + {data?.map(({ eventId, createdAt, urlPath, eventName, visitId, hasData }) => { + const showHeader = !lastDay || !isSameDay(new Date(lastDay), new Date(createdAt)); + lastDay = createdAt; + + return ( + <Column key={eventId} gap> + {showHeader && <Heading size="1">{formatTimezoneDate(createdAt, 'PPPP')}</Heading>} + <Row alignItems="center" gap="6" height="40px"> + <StatusLight color={`#${visitId?.substring(0, 6)}`}> + <Text wrap="nowrap">{formatTimezoneDate(createdAt, 'pp')}</Text> + </StatusLight> + <Row alignItems="center" gap="2"> + <Icon>{eventName ? <Lightning /> : <Eye />}</Icon> + <Text wrap="nowrap"> + {eventName + ? formatMessage(labels.triggeredEvent) + : formatMessage(labels.viewedPage)} + </Text> + <Text weight="bold" style={{ maxWidth: isMobile ? '400px' : null }} truncate> + {eventName || urlPath} + </Text> + {hasData > 0 && <PropertiesButton websiteId={websiteId} eventId={eventId} />} + </Row> + </Row> + </Column> + ); + })} + </Column> + </LoadingPanel> + ); +} + +const PropertiesButton = props => { + return ( + <DialogTrigger> + <Button variant="quiet"> + <Row alignItems="center" gap> + <Icon> + <FileText /> + </Icon> + </Row> + </Button> + <Popover placement="right"> + <Dialog> + <EventData {...props} /> + </Dialog> + </Popover> + </DialogTrigger> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx new file mode 100644 index 0000000..7c82c17 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionData.tsx @@ -0,0 +1,32 @@ +import { Box, Column, Label, Row, Text } from '@umami/react-zen'; +import { Empty } from '@/components/common/Empty'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useSessionDataQuery } from '@/components/hooks'; +import { DATA_TYPES } from '@/lib/constants'; + +export function SessionData({ websiteId, sessionId }: { websiteId: string; sessionId: string }) { + const { data, isLoading, error } = useSessionDataQuery(websiteId, sessionId); + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + {!data?.length && <Empty />} + <Column gap="6"> + {data?.map(({ dataKey, dataType, stringValue }) => { + return ( + <Column key={dataKey}> + <Label>{dataKey}</Label> + <Row alignItems="center" gap> + <Text>{stringValue}</Text> + <Box paddingY="1" paddingX="2" border borderRadius borderColor="5"> + <Text color="muted" size="1"> + {DATA_TYPES[dataType]} + </Text> + </Box> + </Row> + </Column> + ); + })} + </Column> + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx new file mode 100644 index 0000000..f15e6ee --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionInfo.tsx @@ -0,0 +1,85 @@ +import { Column, Grid, Icon, Label, Row } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { DateDistance } from '@/components/common/DateDistance'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useFormat, useLocale, useMessages, useRegionNames } from '@/components/hooks'; +import { Calendar, KeyRound, Landmark, MapPin } from '@/components/icons'; + +export function SessionInfo({ data }) { + const { locale } = useLocale(); + const { formatMessage, labels } = useMessages(); + const { formatValue } = useFormat(); + const { getRegionName } = useRegionNames(locale); + + return ( + <Grid columns="repeat(auto-fit, minmax(200px, 1fr)" gap> + <Info label={formatMessage(labels.distinctId)} icon={<KeyRound />}> + {data?.distinctId} + </Info> + + <Info label={formatMessage(labels.lastSeen)} icon={<Calendar />}> + <DateDistance date={new Date(data.lastAt)} /> + </Info> + + <Info label={formatMessage(labels.firstSeen)} icon={<Calendar />}> + <DateDistance date={new Date(data.firstAt)} /> + </Info> + + <Info + label={formatMessage(labels.country)} + icon={<TypeIcon type="country" value={data?.country} />} + > + {formatValue(data?.country, 'country')} + </Info> + + <Info label={formatMessage(labels.region)} icon={<MapPin />}> + {getRegionName(data?.region)} + </Info> + + <Info label={formatMessage(labels.city)} icon={<Landmark />}> + {data?.city} + </Info> + + <Info + label={formatMessage(labels.browser)} + icon={<TypeIcon type="browser" value={data?.browser} />} + > + {formatValue(data?.browser, 'browser')} + </Info> + + <Info + label={formatMessage(labels.os)} + icon={<TypeIcon type="os" value={data?.os?.toLowerCase()?.replaceAll(/\W/g, '-')} />} + > + {formatValue(data?.os, 'os')} + </Info> + + <Info + label={formatMessage(labels.device)} + icon={<TypeIcon type="device" value={data?.device} />} + > + {formatValue(data?.device, 'device')} + </Info> + </Grid> + ); +} + +const Info = ({ + label, + icon, + children, +}: { + label: string; + icon?: ReactNode; + children: ReactNode; +}) => { + return ( + <Column> + <Label>{label}</Label> + <Row alignItems="center" gap> + {icon && <Icon>{icon}</Icon>} + {children || '—'} + </Row> + </Column> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx new file mode 100644 index 0000000..d658038 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionModal.tsx @@ -0,0 +1,41 @@ +import { Column, Dialog, Modal, type ModalProps } from '@umami/react-zen'; +import { SessionProfile } from '@/app/(main)/websites/[websiteId]/sessions/SessionProfile'; +import { useNavigation } from '@/components/hooks'; + +export interface SessionModalProps extends ModalProps { + websiteId: string; +} + +export function SessionModal({ websiteId, ...props }: SessionModalProps) { + const { + router, + query: { session }, + updateParams, + } = useNavigation(); + const handleOpenChange = (isOpen: boolean) => { + if (!isOpen) { + router.push(updateParams({ session: undefined })); + } + }; + + return ( + <Modal + placement="bottom" + offset="80px" + isOpen={!!session} + onOpenChange={handleOpenChange} + isDismissable + {...props} + > + <Column height="100%" maxWidth="1320px" style={{ margin: '0 auto' }}> + <Dialog variant="sheet"> + {({ close }) => ( + <Column padding="6"> + <SessionProfile websiteId={websiteId} sessionId={session} onClose={() => close()} /> + </Column> + )} + </Dialog> + </Column> + </Modal> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx new file mode 100644 index 0000000..6624d43 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProfile.tsx @@ -0,0 +1,84 @@ +import { + Button, + Column, + Icon, + Row, + Tab, + TabList, + TabPanel, + Tabs, + TextField, +} from '@umami/react-zen'; +import { X } from 'lucide-react'; +import { Avatar } from '@/components/common/Avatar'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useWebsiteSessionQuery } from '@/components/hooks'; +import { SessionActivity } from './SessionActivity'; +import { SessionData } from './SessionData'; +import { SessionInfo } from './SessionInfo'; +import { SessionStats } from './SessionStats'; + +export function SessionProfile({ + websiteId, + sessionId, + onClose, +}: { + websiteId: string; + sessionId: string; + onClose?: () => void; +}) { + const { data, isLoading, error } = useWebsiteSessionQuery(websiteId, sessionId); + const { formatMessage, labels } = useMessages(); + + return ( + <LoadingPanel + data={data} + isLoading={isLoading} + error={error} + loadingIcon="spinner" + loadingPlacement="absolute" + > + {data && ( + <Column gap> + {onClose && ( + <Row justifyContent="flex-end"> + <Button onPress={onClose} variant="quiet"> + <Icon> + <X /> + </Icon> + </Button> + </Row> + )} + <Column gap="6"> + <Row justifyContent="center" alignItems="center" gap="6"> + <Avatar seed={data?.id} size={128} /> + <Column width="360px"> + <TextField label="ID" value={data?.id} allowCopy /> + </Column> + </Row> + <SessionStats data={data} /> + <SessionInfo data={data} /> + + <Tabs> + <TabList> + <Tab id="activity">{formatMessage(labels.activity)}</Tab> + <Tab id="properties">{formatMessage(labels.properties)}</Tab> + </TabList> + <TabPanel id="activity"> + <SessionActivity + websiteId={websiteId} + sessionId={sessionId} + startDate={data?.firstAt} + endDate={data?.lastAt} + /> + </TabPanel> + <TabPanel id="properties"> + <SessionData sessionId={sessionId} websiteId={websiteId} /> + </TabPanel> + </Tabs> + </Column> + </Column> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx new file mode 100644 index 0000000..1693d05 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx @@ -0,0 +1,97 @@ +import { Column, Grid, ListItem, Select } from '@umami/react-zen'; +import { useMemo, useState } from 'react'; +import { PieChart } from '@/components/charts/PieChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { + useMessages, + useSessionDataPropertiesQuery, + useSessionDataValuesQuery, +} from '@/components/hooks'; +import { ListTable } from '@/components/metrics/ListTable'; +import { CHART_COLORS } from '@/lib/constants'; + +export function SessionProperties({ websiteId }: { websiteId: string }) { + const [propertyName, setPropertyName] = useState(''); + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useSessionDataPropertiesQuery(websiteId); + + const properties: string[] = data?.map(e => e.propertyName); + + return ( + <LoadingPanel + isLoading={isLoading} + isFetching={isFetching} + data={data} + error={error} + minHeight="300px" + > + <Column gap="6"> + {data && ( + <Grid columns="repeat(auto-fill, minmax(300px, 1fr))" gap> + <Select + label={formatMessage(labels.event)} + value={propertyName} + onChange={setPropertyName} + placeholder="" + > + {properties?.map(p => ( + <ListItem key={p} id={p}> + {p} + </ListItem> + ))} + </Select> + </Grid> + )} + {propertyName && <SessionValues websiteId={websiteId} propertyName={propertyName} />} + </Column> + </LoadingPanel> + ); +} + +const SessionValues = ({ websiteId, propertyName }) => { + const { data, isLoading, isFetching, error } = useSessionDataValuesQuery(websiteId, propertyName); + + const propertySum = useMemo(() => { + return data?.reduce((sum, { total }) => sum + total, 0) ?? 0; + }, [data]); + + const chartData = useMemo(() => { + if (!propertyName || !data) return null; + return { + labels: data.map(({ value }) => value), + datasets: [ + { + data: data.map(({ total }) => total), + backgroundColor: CHART_COLORS, + borderWidth: 0, + }, + ], + }; + }, [propertyName, data]); + + const tableData = useMemo(() => { + if (!propertyName || !data || propertySum === 0) return []; + return data.map(({ value, total }) => ({ + label: value, + count: total, + percent: 100 * (total / propertySum), + })); + }, [propertyName, data, propertySum]); + + return ( + <LoadingPanel + isLoading={isLoading} + isFetching={isFetching} + data={data} + error={error} + minHeight="300px" + > + {data && ( + <Grid columns="1fr 1fr" gap> + <ListTable title={propertyName} data={tableData} /> + <PieChart type="doughnut" chartData={chartData} /> + </Grid> + )} + </LoadingPanel> + ); +}; diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx new file mode 100644 index 0000000..e25be9a --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionStats.tsx @@ -0,0 +1,21 @@ +import { useMessages } from '@/components/hooks'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { formatShortTime } from '@/lib/format'; + +export function SessionStats({ data }) { + const { formatMessage, labels } = useMessages(); + + return ( + <MetricsBar> + <MetricCard label={formatMessage(labels.visits)} value={data?.visits} /> + <MetricCard label={formatMessage(labels.views)} value={data?.views} /> + <MetricCard label={formatMessage(labels.events)} value={data?.events} /> + <MetricCard + label={formatMessage(labels.visitDuration)} + value={data?.totaltime / data?.visits} + formatValue={n => `${+n < 0 ? '-' : ''}${formatShortTime(Math.abs(~~n), ['m', 's'], ' ')}`} + /> + </MetricsBar> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx new file mode 100644 index 0000000..b1b9f65 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsDataTable.tsx @@ -0,0 +1,15 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useWebsiteSessionsQuery } from '@/components/hooks'; +import { SessionsTable } from './SessionsTable'; + +export function SessionsDataTable({ websiteId }: { websiteId?: string; teamId?: string }) { + const queryResult = useWebsiteSessionsQuery(websiteId); + + return ( + <DataGrid query={queryResult} allowPaging allowSearch> + {({ data }) => { + return <SessionsTable data={data} />; + }} + </DataGrid> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx new file mode 100644 index 0000000..c8317a2 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsMetricsBar.tsx @@ -0,0 +1,40 @@ +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages } from '@/components/hooks'; +import { useWebsiteSessionStatsQuery } from '@/components/hooks/queries/useWebsiteSessionStatsQuery'; +import { MetricCard } from '@/components/metrics/MetricCard'; +import { MetricsBar } from '@/components/metrics/MetricsBar'; +import { formatLongNumber } from '@/lib/format'; + +export function SessionsMetricsBar({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useWebsiteSessionStatsQuery(websiteId); + + return ( + <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} error={error}> + {data && ( + <MetricsBar> + <MetricCard + value={data?.visitors?.value} + label={formatMessage(labels.visitors)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.visits?.value} + label={formatMessage(labels.visits)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.pageviews?.value} + label={formatMessage(labels.views)} + formatValue={formatLongNumber} + /> + <MetricCard + value={data?.countries?.value} + label={formatMessage(labels.countries)} + formatValue={formatLongNumber} + /> + </MetricsBar> + )} + </LoadingPanel> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx new file mode 100644 index 0000000..8e9d2f2 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx @@ -0,0 +1,43 @@ +'use client'; +import { Column, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { type Key, useState } from 'react'; +import { SessionModal } from '@/app/(main)/websites/[websiteId]/sessions/SessionModal'; +import { WebsiteControls } from '@/app/(main)/websites/[websiteId]/WebsiteControls'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { getItem, setItem } from '@/lib/storage'; +import { SessionProperties } from './SessionProperties'; +import { SessionsDataTable } from './SessionsDataTable'; + +const KEY_NAME = 'umami.sessions.tab'; + +export function SessionsPage({ websiteId }) { + const [tab, setTab] = useState(getItem(KEY_NAME) || 'activity'); + const { formatMessage, labels } = useMessages(); + + const handleSelect = (value: Key) => { + setItem(KEY_NAME, value); + setTab(value); + }; + + return ( + <Column gap="3"> + <WebsiteControls websiteId={websiteId} /> + <Panel> + <Tabs selectedKey={tab} onSelectionChange={handleSelect}> + <TabList> + <Tab id="activity">{formatMessage(labels.activity)}</Tab> + <Tab id="properties">{formatMessage(labels.properties)}</Tab> + </TabList> + <TabPanel id="activity"> + <SessionsDataTable websiteId={websiteId} /> + </TabPanel> + <TabPanel id="properties"> + <SessionProperties websiteId={websiteId} /> + </TabPanel> + </Tabs> + </Panel> + <SessionModal websiteId={websiteId} /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx new file mode 100644 index 0000000..5d3bb37 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsTable.tsx @@ -0,0 +1,58 @@ +import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen'; +import Link from 'next/link'; +import { Avatar } from '@/components/common/Avatar'; +import { DateDistance } from '@/components/common/DateDistance'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { useFormat, useMessages, useNavigation } from '@/components/hooks'; + +export function SessionsTable(props: DataTableProps) { + const { formatMessage, labels } = useMessages(); + const { formatValue } = useFormat(); + const { updateParams } = useNavigation(); + + return ( + <DataTable {...props}> + <DataColumn id="id" label={formatMessage(labels.session)} width="100px"> + {(row: any) => ( + <Link href={updateParams({ session: row.id })}> + <Avatar seed={row.id} size={32} /> + </Link> + )} + </DataColumn> + <DataColumn id="visits" label={formatMessage(labels.visits)} width="80px" /> + <DataColumn id="views" label={formatMessage(labels.views)} width="80px" /> + <DataColumn id="country" label={formatMessage(labels.country)}> + {(row: any) => ( + <TypeIcon type="country" value={row.country}> + {formatValue(row.country, 'country')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="city" label={formatMessage(labels.city)} /> + <DataColumn id="browser" label={formatMessage(labels.browser)}> + {(row: any) => ( + <TypeIcon type="browser" value={row.browser}> + {formatValue(row.browser, 'browser')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="os" label={formatMessage(labels.os)}> + {(row: any) => ( + <TypeIcon type="os" value={row.os}> + {formatValue(row.os, 'os')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="device" label={formatMessage(labels.device)}> + {(row: any) => ( + <TypeIcon type="device" value={row.device}> + {formatValue(row.device, 'device')} + </TypeIcon> + )} + </DataColumn> + <DataColumn id="lastAt" label={formatMessage(labels.lastSeen)}> + {(row: any) => <DateDistance date={new Date(row.createdAt)} />} + </DataColumn> + </DataTable> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/page.tsx b/src/app/(main)/websites/[websiteId]/sessions/page.tsx new file mode 100644 index 0000000..221ab71 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { SessionsPage } from './SessionsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <SessionsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Sessions', +}; diff --git a/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx new file mode 100644 index 0000000..468f250 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/SettingsPage.tsx @@ -0,0 +1,6 @@ +'use client'; +import { WebsiteSettingsPage } from '@/app/(main)/settings/websites/[websiteId]/WebsiteSettingsPage'; + +export function SettingsPage({ websiteId }: { websiteId: string }) { + return <WebsiteSettingsPage websiteId={websiteId} />; +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx new file mode 100644 index 0000000..21cd613 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteData.tsx @@ -0,0 +1,104 @@ +import { Button, Column, Dialog, DialogTrigger, Modal } from '@umami/react-zen'; +import { ActionForm } from '@/components/common/ActionForm'; +import { + useLoginQuery, + useMessages, + useModified, + useNavigation, + useUserTeamsQuery, +} from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; +import { WebsiteDeleteForm } from './WebsiteDeleteForm'; +import { WebsiteResetForm } from './WebsiteResetForm'; +import { WebsiteTransferForm } from './WebsiteTransferForm'; + +export function WebsiteData({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) { + const { formatMessage, labels, messages } = useMessages(); + const { user } = useLoginQuery(); + const { touch } = useModified(); + const { router, pathname, teamId, renderUrl } = useNavigation(); + const { data: teams } = useUserTeamsQuery(user.id); + const isAdmin = pathname.startsWith('/admin'); + + const canTransferWebsite = + ( + (!teamId && + teams?.data?.filter(({ members }) => + members.find( + ({ role, userId }) => + [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id, + ), + )) || + [] + ).length > 0 || + (teamId && + !!teams?.data + ?.find(({ id }) => id === teamId) + ?.members.find(({ role, userId }) => role === ROLES.teamOwner && userId === user.id)); + + const handleSave = () => { + touch('websites'); + onSave?.(); + router.push(renderUrl(`/websites`)); + }; + + const handleReset = async () => { + onSave?.(); + }; + + return ( + <Column gap="6"> + {!isAdmin && ( + <ActionForm + label={formatMessage(labels.transferWebsite)} + description={formatMessage(messages.transferWebsite)} + > + <DialogTrigger> + <Button isDisabled={!canTransferWebsite}>{formatMessage(labels.transfer)}</Button> + <Modal> + <Dialog title={formatMessage(labels.transferWebsite)} style={{ width: 400 }}> + {({ close }) => ( + <WebsiteTransferForm websiteId={websiteId} onSave={handleSave} onClose={close} /> + )} + </Dialog> + </Modal> + </DialogTrigger> + </ActionForm> + )} + + <ActionForm + label={formatMessage(labels.resetWebsite)} + description={formatMessage(messages.resetWebsiteWarning)} + > + <DialogTrigger> + <Button>{formatMessage(labels.reset)}</Button> + <Modal> + <Dialog title={formatMessage(labels.resetWebsite)} style={{ width: 400 }}> + {({ close }) => ( + <WebsiteResetForm websiteId={websiteId} onSave={handleReset} onClose={close} /> + )} + </Dialog> + </Modal> + </DialogTrigger> + </ActionForm> + + <ActionForm + label={formatMessage(labels.deleteWebsite)} + description={formatMessage(messages.deleteWebsiteWarning)} + > + <DialogTrigger> + <Button data-test="button-delete" variant="danger"> + {formatMessage(labels.delete)} + </Button> + <Modal> + <Dialog title={formatMessage(labels.deleteWebsite)} style={{ width: 400 }}> + {({ close }) => ( + <WebsiteDeleteForm websiteId={websiteId} onSave={handleSave} onClose={close} /> + )} + </Dialog> + </Modal> + </DialogTrigger> + </ActionForm> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx new file mode 100644 index 0000000..2fc0276 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm.tsx @@ -0,0 +1,40 @@ +import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; + +const CONFIRM_VALUE = 'DELETE'; + +export function WebsiteDeleteForm({ + websiteId, + onSave, + onClose, +}: { + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { mutateAsync, isPending, error, touch } = useDeleteQuery(`/websites/${websiteId}`); + + const handleConfirm = async () => { + await mutateAsync(null, { + onSuccess: async () => { + touch('websites'); + touch(`websites:${websiteId}`); + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <TypeConfirmationForm + confirmationValue={CONFIRM_VALUE} + onConfirm={handleConfirm} + onClose={onClose} + isLoading={isPending} + error={error} + buttonLabel={formatMessage(labels.delete)} + buttonVariant="danger" + /> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx new file mode 100644 index 0000000..4ae819e --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteEditForm.tsx @@ -0,0 +1,55 @@ +import { Form, FormButtons, FormField, FormSubmitButton, TextField } from '@umami/react-zen'; +import { useMessages, useUpdateQuery, useWebsite } from '@/components/hooks'; +import { DOMAIN_REGEX } from '@/lib/constants'; + +export function WebsiteEditForm({ websiteId, onSave }: { websiteId: string; onSave?: () => void }) { + const website = useWebsite(); + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch('websites'); + touch(`website:${website.id}`); + onSave?.(); + }, + }); + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={website}> + <FormField name="id" label={formatMessage(labels.websiteId)}> + <TextField data-test="text-field-websiteId" value={website?.id} isReadOnly allowCopy /> + </FormField> + <FormField + label={formatMessage(labels.name)} + data-test="input-name" + name="name" + rules={{ required: formatMessage(labels.required) }} + > + <TextField /> + </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 /> + </FormField> + <FormButtons> + <FormSubmitButton data-test="button-submit" variant="primary"> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx new file mode 100644 index 0000000..d791bc9 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteResetForm.tsx @@ -0,0 +1,37 @@ +import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; + +const CONFIRM_VALUE = 'RESET'; + +export function WebsiteResetForm({ + websiteId, + onSave, + onClose, +}: { + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + const { mutateAsync, isPending, error } = useUpdateQuery(`/websites/${websiteId}/reset`); + + const handleConfirm = async () => { + await mutateAsync(null, { + onSuccess: async () => { + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <TypeConfirmationForm + confirmationValue={CONFIRM_VALUE} + onConfirm={handleConfirm} + onClose={onClose} + isLoading={isPending} + error={error} + buttonLabel={formatMessage(labels.reset)} + /> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx new file mode 100644 index 0000000..3970cdb --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettings.tsx @@ -0,0 +1,28 @@ +import { Column } from '@umami/react-zen'; +import { Panel } from '@/components/common/Panel'; +import { useWebsite } from '@/components/hooks'; +import { WebsiteData } from './WebsiteData'; +import { WebsiteEditForm } from './WebsiteEditForm'; +import { WebsiteShareForm } from './WebsiteShareForm'; +import { WebsiteTrackingCode } from './WebsiteTrackingCode'; + +export function WebsiteSettings({ websiteId }: { websiteId: string; openExternal?: boolean }) { + const website = useWebsite(); + + return ( + <Column gap="6"> + <Panel> + <WebsiteEditForm websiteId={websiteId} /> + </Panel> + <Panel> + <WebsiteTrackingCode websiteId={websiteId} /> + </Panel> + <Panel> + <WebsiteShareForm websiteId={websiteId} shareId={website.shareId} /> + </Panel> + <Panel> + <WebsiteData websiteId={websiteId} /> + </Panel> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx new file mode 100644 index 0000000..99977a0 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteSettingsHeader.tsx @@ -0,0 +1,22 @@ +import { IconLabel, Row } from '@umami/react-zen'; +import Link from 'next/link'; +import { PageHeader } from '@/components/common/PageHeader'; +import { useMessages, useNavigation, useWebsite } from '@/components/hooks'; +import { ArrowLeft, Globe } from '@/components/icons'; + +export function WebsiteSettingsHeader() { + const website = useWebsite(); + const { formatMessage, labels } = useMessages(); + const { renderUrl } = useNavigation(); + + return ( + <> + <Row marginTop="6"> + <Link href={renderUrl(`/websites/${website.id}`)}> + <IconLabel icon={<ArrowLeft />} label={formatMessage(labels.website)} /> + </Link> + </Row> + <PageHeader title={website?.name} description={website?.domain} icon={<Globe />} /> + </> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx new file mode 100644 index 0000000..56c6f43 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteShareForm.tsx @@ -0,0 +1,93 @@ +import { + Button, + Column, + Form, + FormButtons, + FormSubmitButton, + IconLabel, + Label, + Row, + Switch, + TextField, +} from '@umami/react-zen'; +import { RefreshCcw } from 'lucide-react'; +import { useState } from 'react'; +import { useConfig, useMessages, useUpdateQuery } from '@/components/hooks'; +import { getRandomChars } from '@/lib/generate'; + +const generateId = () => getRandomChars(16); + +export interface WebsiteShareFormProps { + websiteId: string; + shareId?: string; + onSave?: () => void; + onClose?: () => void; +} + +export function WebsiteShareForm({ websiteId, shareId, onSave, onClose }: WebsiteShareFormProps) { + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + const [currentId, setCurrentId] = useState(shareId); + const { mutateAsync, error, touch, toast } = useUpdateQuery(`/websites/${websiteId}`); + const { cloudMode } = useConfig(); + + const getUrl = (shareId: string) => { + if (cloudMode) { + return `${process.env.cloudUrl}/share/${shareId}`; + } + + return `${window?.location.origin}${process.env.basePath || ''}/share/${shareId}`; + }; + + const url = getUrl(currentId); + + const handleGenerate = () => { + setCurrentId(generateId()); + }; + + const handleSwitch = () => { + setCurrentId(currentId ? null : generateId()); + }; + + const handleSave = async () => { + const data = { + shareId: currentId, + }; + await mutateAsync(data, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch(`website:${websiteId}`); + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <Form onSubmit={handleSave} error={getErrorMessage(error)} values={{ url }}> + <Column gap> + <Switch isSelected={!!currentId} onChange={handleSwitch}> + {formatMessage(labels.enableShareUrl)} + </Switch> + {currentId && ( + <Row alignItems="flex-end" gap> + <Column flexGrow={1}> + <Label>{formatMessage(labels.shareUrl)}</Label> + <TextField value={url} isReadOnly allowCopy /> + </Column> + <Column> + <Button onPress={handleGenerate}> + <IconLabel icon={<RefreshCcw />} label={formatMessage(labels.regenerate)} /> + </Button> + </Column> + </Row> + )} + <FormButtons justifyContent="flex-end"> + <Row alignItems="center" gap> + {onClose && <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button>} + <FormSubmitButton isDisabled={false}>{formatMessage(labels.save)}</FormSubmitButton> + </Row> + </FormButtons> + </Column> + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx new file mode 100644 index 0000000..d24f948 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode.tsx @@ -0,0 +1,40 @@ +import { Column, Label, Text, TextField } from '@umami/react-zen'; +import { useConfig, useMessages } from '@/components/hooks'; + +const SCRIPT_NAME = 'script.js'; + +export function WebsiteTrackingCode({ + websiteId, + hostUrl, +}: { + websiteId: string; + hostUrl?: string; +}) { + const { formatMessage, messages, labels } = useMessages(); + const config = useConfig(); + + const trackerScriptName = + config?.trackerScriptName?.split(',')?.map((n: string) => n.trim())?.[0] || SCRIPT_NAME; + + const getUrl = () => { + if (config?.cloudMode) { + return `${process.env.cloudUrl}/${trackerScriptName}`; + } + + return `${hostUrl || window?.location?.origin || ''}${ + process.env.basePath || '' + }/${trackerScriptName}`; + }; + + const url = trackerScriptName?.startsWith('http') ? trackerScriptName : getUrl(); + + const code = `<script defer src="${url}" data-website-id="${websiteId}"></script>`; + + return ( + <Column gap> + <Label>{formatMessage(labels.trackingCode)}</Label> + <Text color="muted">{formatMessage(messages.trackingCode)}</Text> + <TextField value={code} isReadOnly allowCopy asTextArea resize="none" /> + </Column> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx new file mode 100644 index 0000000..8af4f05 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/WebsiteTransferForm.tsx @@ -0,0 +1,102 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + ListItem, + Loading, + Select, + Text, +} from '@umami/react-zen'; +import { type Key, useState } from 'react'; +import { + useLoginQuery, + useMessages, + useUpdateQuery, + useUserTeamsQuery, + useWebsite, +} from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; + +export function WebsiteTransferForm({ + websiteId, + onSave, + onClose, +}: { + websiteId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { user } = useLoginQuery(); + const website = useWebsite(); + const [teamId, setTeamId] = useState<string>(null); + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending } = useUpdateQuery(`/websites/${websiteId}/transfer`); + const { data: teams, isLoading } = useUserTeamsQuery(user.id); + const isTeamWebsite = !!website?.teamId; + + const items = + teams?.data?.filter(({ members }) => + members.some( + ({ role, userId }) => + [ROLES.teamOwner, ROLES.teamManager].includes(role) && userId === user.id, + ), + ) || []; + + const handleSubmit = async () => { + await mutateAsync( + { + userId: website.teamId ? user.id : undefined, + teamId: website.userId ? teamId : undefined, + }, + { + onSuccess: async () => { + onSave?.(); + onClose?.(); + }, + }, + ); + }; + + const handleChange = (key: Key) => { + setTeamId(key as string); + }; + + if (isLoading) { + return <Loading icon="dots" placement="center" />; + } + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)} values={{ teamId }}> + <Text> + {formatMessage( + isTeamWebsite ? messages.transferTeamWebsiteToUser : messages.transferUserWebsiteToTeam, + )} + </Text> + <FormField name="teamId"> + {!isTeamWebsite && ( + <Select onSelectionChange={handleChange} selectedKey={teamId}> + {items.map(({ id, name }) => { + return ( + <ListItem key={`${id}`} id={`${id}`}> + {name} + </ListItem> + ); + })} + </Select> + )} + </FormField> + <FormButtons> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <FormSubmitButton + variant="primary" + isPending={isPending} + isDisabled={!isTeamWebsite && !teamId} + > + {formatMessage(labels.transfer)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/websites/[websiteId]/settings/page.tsx b/src/app/(main)/websites/[websiteId]/settings/page.tsx new file mode 100644 index 0000000..a26d14f --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/settings/page.tsx @@ -0,0 +1,12 @@ +import type { Metadata } from 'next'; +import { SettingsPage } from './SettingsPage'; + +export default async function ({ params }: { params: Promise<{ websiteId: string }> }) { + const { websiteId } = await params; + + return <SettingsPage websiteId={websiteId} />; +} + +export const metadata: Metadata = { + title: 'Settings', +}; diff --git a/src/app/(main)/websites/page.tsx b/src/app/(main)/websites/page.tsx new file mode 100644 index 0000000..cefaf80 --- /dev/null +++ b/src/app/(main)/websites/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next'; +import { WebsitesPage } from './WebsitesPage'; + +export default function () { + return <WebsitesPage />; +} + +export const metadata: Metadata = { + title: 'Websites', +}; |