diff options
Diffstat (limited to 'src')
725 files changed, 51733 insertions, 0 deletions
diff --git a/src/app/(collect)/p/[slug]/route.ts b/src/app/(collect)/p/[slug]/route.ts new file mode 100644 index 0000000..79d6faa --- /dev/null +++ b/src/app/(collect)/p/[slug]/route.ts @@ -0,0 +1,68 @@ +export const dynamic = 'force-dynamic'; + +import { NextResponse } from 'next/server'; +import { POST } from '@/app/api/send/route'; +import type { Pixel } from '@/generated/prisma/client'; +import redis from '@/lib/redis'; +import { notFound } from '@/lib/response'; +import { findPixel } from '@/queries/prisma'; + +const image = Buffer.from('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw', 'base64'); + +export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + + let pixel: Pixel; + + if (redis.enabled) { + pixel = await redis.client.fetch( + `pixel:${slug}`, + async () => { + return findPixel({ + where: { + slug, + }, + }); + }, + 86400, + ); + + if (!pixel) { + return notFound(); + } + } else { + pixel = await findPixel({ + where: { + slug, + }, + }); + + if (!pixel) { + return notFound(); + } + } + + const payload = { + type: 'event', + payload: { + pixel: pixel.id, + url: request.url, + referrer: request.headers.get('referer'), + }, + }; + + const req = new Request(request.url, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(payload), + }); + + await POST(req); + + return new NextResponse(image, { + headers: { + 'Content-Type': 'image/gif', + 'Content-Length': image.length.toString(), + }, + }); +} diff --git a/src/app/(collect)/q/[slug]/route.ts b/src/app/(collect)/q/[slug]/route.ts new file mode 100644 index 0000000..24089bd --- /dev/null +++ b/src/app/(collect)/q/[slug]/route.ts @@ -0,0 +1,61 @@ +export const dynamic = 'force-dynamic'; + +import { NextResponse } from 'next/server'; +import { POST } from '@/app/api/send/route'; +import type { Link } from '@/generated/prisma/client'; +import redis from '@/lib/redis'; +import { notFound } from '@/lib/response'; +import { findLink } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ slug: string }> }) { + const { slug } = await params; + + let link: Link; + + if (redis.enabled) { + link = await redis.client.fetch( + `link:${slug}`, + async () => { + return findLink({ + where: { + slug, + }, + }); + }, + 86400, + ); + + if (!link) { + return notFound(); + } + } else { + link = await findLink({ + where: { + slug, + }, + }); + + if (!link) { + return notFound(); + } + } + + const payload = { + type: 'event', + payload: { + link: link.id, + url: request.url, + referrer: request.headers.get('referer'), + }, + }; + + const req = new Request(request.url, { + method: 'POST', + headers: request.headers, + body: JSON.stringify(payload), + }); + + await POST(req); + + return NextResponse.redirect(link.url); +} 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', +}; diff --git a/src/app/Providers.tsx b/src/app/Providers.tsx new file mode 100644 index 0000000..ae1a000 --- /dev/null +++ b/src/app/Providers.tsx @@ -0,0 +1,62 @@ +'use client'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider, ZenProvider } from '@umami/react-zen'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { IntlProvider } from 'react-intl'; +import { ErrorBoundary } from '@/components/common/ErrorBoundary'; +import { useLocale } from '@/components/hooks'; +import 'chartjs-adapter-date-fns'; + +const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + staleTime: 1000 * 60, + }, + }, +}); + +function MessagesProvider({ children }) { + const { locale, messages, dir } = useLocale(); + + useEffect(() => { + document.documentElement.setAttribute('dir', dir); + document.documentElement.setAttribute('lang', locale); + }, [locale, dir]); + + return ( + <IntlProvider locale={locale} messages={messages[locale]} onError={() => null}> + {children} + </IntlProvider> + ); +} + +export function Providers({ children }) { + const router = useRouter(); + + function navigate(url: string) { + if (shouldUseNativeLink(url)) { + window.location.href = url; + } else { + router.push(url); + } + } + + function shouldUseNativeLink(url: string) { + return url.startsWith('http'); + } + + return ( + <ZenProvider> + <RouterProvider navigate={navigate}> + <MessagesProvider> + <QueryClientProvider client={client}> + <ErrorBoundary>{children}</ErrorBoundary> + </QueryClientProvider> + </MessagesProvider> + </RouterProvider> + </ZenProvider> + ); +} diff --git a/src/app/api/admin/teams/route.ts b/src/app/api/admin/teams/route.ts new file mode 100644 index 0000000..ceb16ab --- /dev/null +++ b/src/app/api/admin/teams/route.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewAllTeams } from '@/permissions'; +import { getTeams } from '@/queries/prisma/team'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewAllTeams(auth))) { + return unauthorized(); + } + + const teams = await getTeams( + { + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, + _count: { + select: { + websites: { + where: { deletedAt: null }, + }, + members: { + where: { + user: { deletedAt: null }, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + query, + ); + + return json(teams); +} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts new file mode 100644 index 0000000..2e52261 --- /dev/null +++ b/src/app/api/admin/users/route.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewUsers } from '@/permissions'; +import { getUsers } from '@/queries/prisma/user'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewUsers(auth))) { + return unauthorized(); + } + + const users = await getUsers( + { + include: { + _count: { + select: { + websites: { + where: { deletedAt: null }, + }, + }, + }, + }, + omit: { + password: true, + }, + orderBy: { + createdAt: 'desc', + }, + }, + query, + ); + + return json(users); +} diff --git a/src/app/api/admin/websites/route.ts b/src/app/api/admin/websites/route.ts new file mode 100644 index 0000000..09b2ef9 --- /dev/null +++ b/src/app/api/admin/websites/route.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; +import { ROLES } from '@/lib/constants'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewAllWebsites } from '@/permissions'; +import { getWebsites } from '@/queries/prisma/website'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewAllWebsites(auth))) { + return unauthorized(); + } + + const websites = await getWebsites( + { + include: { + user: { + where: { + deletedAt: null, + }, + select: { + username: true, + id: true, + }, + }, + team: { + where: { + deletedAt: null, + }, + include: { + members: { + where: { + role: ROLES.teamOwner, + }, + }, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, + query, + ); + + return json(websites); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..17ca2f7 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import { saveAuth } from '@/lib/auth'; +import { ROLES } from '@/lib/constants'; +import { secret } from '@/lib/crypto'; +import { createSecureToken } from '@/lib/jwt'; +import { checkPassword } from '@/lib/password'; +import redis from '@/lib/redis'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { getAllUserTeams, getUserByUsername } from '@/queries/prisma'; + +export async function POST(request: Request) { + const schema = z.object({ + username: z.string(), + password: z.string(), + }); + + const { body, error } = await parseRequest(request, schema, { skipAuth: true }); + + if (error) { + return error(); + } + + const { username, password } = body; + + const user = await getUserByUsername(username, { includePassword: true }); + + if (!user || !checkPassword(password, user.password)) { + return unauthorized({ code: 'incorrect-username-password' }); + } + + const { id, role, createdAt } = user; + + let token: string; + + if (redis.enabled) { + token = await saveAuth({ userId: id, role }); + } else { + token = createSecureToken({ userId: user.id, role }, secret()); + } + + const teams = await getAllUserTeams(id); + + return json({ + token, + user: { id, username, role, createdAt, isAdmin: role === ROLES.admin, teams }, + }); +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..7bf0a81 --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,12 @@ +import redis from '@/lib/redis'; +import { ok } from '@/lib/response'; + +export async function POST(request: Request) { + if (redis.enabled) { + const token = request.headers.get('authorization')?.split(' ')?.[1]; + + await redis.client.del(token); + } + + return ok(); +} diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts new file mode 100644 index 0000000..bba3dde --- /dev/null +++ b/src/app/api/auth/sso/route.ts @@ -0,0 +1,18 @@ +import { saveAuth } from '@/lib/auth'; +import redis from '@/lib/redis'; +import { parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; + +export async function POST(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + if (redis.enabled) { + const token = await saveAuth({ userId: auth.user.id }, 86400); + + return json({ user: auth.user, token }); + } +} diff --git a/src/app/api/auth/verify/route.ts b/src/app/api/auth/verify/route.ts new file mode 100644 index 0000000..b308b7b --- /dev/null +++ b/src/app/api/auth/verify/route.ts @@ -0,0 +1,15 @@ +import { parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; +import { getAllUserTeams } from '@/queries/prisma'; + +export async function POST(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const teams = await getAllUserTeams(auth.user.id); + + return json({ ...auth.user, teams }); +} diff --git a/src/app/api/batch/route.ts b/src/app/api/batch/route.ts new file mode 100644 index 0000000..46e8b3c --- /dev/null +++ b/src/app/api/batch/route.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; +import * as send from '@/app/api/send/route'; +import { parseRequest } from '@/lib/request'; +import { json, serverError } from '@/lib/response'; +import { anyObjectParam } from '@/lib/schema'; + +const schema = z.array(anyObjectParam); + +export async function POST(request: Request) { + try { + const { body, error } = await parseRequest(request, schema, { skipAuth: true }); + + if (error) { + return error(); + } + + const errors = []; + + let index = 0; + let cache = null; + for (const data of body) { + // Recreate a fresh Request since `new Request(request)` will have the following error: + // > Cannot read private member #state from an object whose class did not declare it + + // Copy headers we received, ensure JSON content type, and avoid conflicting content-length + const headers = new Headers(request.headers); + headers.set('content-type', 'application/json'); + headers.delete('content-length'); + + const newRequest = new Request(request.url, { + method: 'POST', + headers, + body: JSON.stringify(data), + }); + + const response = await send.POST(newRequest); + const responseJson = await response.json(); + + if (!response.ok) { + errors.push({ index, response: responseJson }); + } else { + cache ??= responseJson.cache; + } + + index++; + } + + return json({ + size: body.length, + processed: body.length - errors.length, + errors: errors.length, + details: errors, + cache, + }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/config/route.ts b/src/app/api/config/route.ts new file mode 100644 index 0000000..4e40caa --- /dev/null +++ b/src/app/api/config/route.ts @@ -0,0 +1,21 @@ +import { parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; + +export async function GET(request: Request) { + const { error } = await parseRequest(request, null, { skipAuth: true }); + + if (error) { + return error(); + } + + return json({ + cloudMode: !!process.env.CLOUD_MODE, + faviconUrl: process.env.FAVICON_URL, + linksUrl: process.env.LINKS_URL, + pixelsUrl: process.env.PIXELS_URL, + privateMode: !!process.env.PRIVATE_MODE, + telemetryDisabled: !!process.env.DISABLE_TELEMETRY, + trackerScriptName: process.env.TRACKER_SCRIPT_NAME, + updatesDisabled: !!process.env.DISABLE_UPDATES, + }); +} diff --git a/src/app/api/heartbeat/route.ts b/src/app/api/heartbeat/route.ts new file mode 100644 index 0000000..9146308 --- /dev/null +++ b/src/app/api/heartbeat/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ ok: true }); +} diff --git a/src/app/api/links/[linkId]/route.ts b/src/app/api/links/[linkId]/route.ts new file mode 100644 index 0000000..92f572c --- /dev/null +++ b/src/app/api/links/[linkId]/route.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; +import { canDeleteLink, canUpdateLink, canViewLink } from '@/permissions'; +import { deleteLink, getLink, updateLink } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ linkId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { linkId } = await params; + + if (!(await canViewLink(auth, linkId))) { + return unauthorized(); + } + + const website = await getLink(linkId); + + return json(website); +} + +export async function POST(request: Request, { params }: { params: Promise<{ linkId: string }> }) { + const schema = z.object({ + name: z.string().optional(), + url: z.string().optional(), + slug: z.string().min(8).optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { linkId } = await params; + const { name, url, slug } = body; + + if (!(await canUpdateLink(auth, linkId))) { + return unauthorized(); + } + + try { + const result = await updateLink(linkId, { name, url, slug }); + + return Response.json(result); + } catch (e: any) { + if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) { + return badRequest({ message: 'That slug is already taken.' }); + } + + return serverError(e); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ linkId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { linkId } = await params; + + if (!(await canDeleteLink(auth, linkId))) { + return unauthorized(); + } + + await deleteLink(linkId); + + return ok(); +} diff --git a/src/app/api/links/route.ts b/src/app/api/links/route.ts new file mode 100644 index 0000000..a639888 --- /dev/null +++ b/src/app/api/links/route.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; +import { createLink, getUserLinks } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + const links = await getUserLinks(auth.user.id, filters); + + return json(links); +} + +export async function POST(request: Request) { + const schema = z.object({ + name: z.string().max(100), + url: z.string().max(500), + slug: z.string().max(100), + teamId: z.string().nullable().optional(), + id: z.uuid().nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { id, name, url, slug, teamId } = body; + + if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { + return unauthorized(); + } + + const data: any = { + id: id ?? uuid(), + name, + url, + slug, + teamId, + }; + + if (!teamId) { + data.userId = auth.user.id; + } + + const result = await createLink(data); + + return json(result); +} diff --git a/src/app/api/me/password/route.ts b/src/app/api/me/password/route.ts new file mode 100644 index 0000000..24c7370 --- /dev/null +++ b/src/app/api/me/password/route.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { checkPassword, hashPassword } from '@/lib/password'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json } from '@/lib/response'; +import { getUser, updateUser } from '@/queries/prisma/user'; + +export async function POST(request: Request) { + const schema = z.object({ + currentPassword: z.string(), + newPassword: z.string().min(8), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const userId = auth.user.id; + const { currentPassword, newPassword } = body; + + const user = await getUser(userId, { includePassword: true }); + + if (!checkPassword(currentPassword, user.password)) { + return badRequest({ message: 'Current password is incorrect' }); + } + + const password = hashPassword(newPassword); + + const updated = await updateUser(userId, { password }); + + return json(updated); +} diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts new file mode 100644 index 0000000..59a3255 --- /dev/null +++ b/src/app/api/me/route.ts @@ -0,0 +1,12 @@ +import { parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; + +export async function GET(request: Request) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + return json(auth); +} diff --git a/src/app/api/me/teams/route.ts b/src/app/api/me/teams/route.ts new file mode 100644 index 0000000..555bf30 --- /dev/null +++ b/src/app/api/me/teams/route.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; +import { pagingParams } from '@/lib/schema'; +import { getUserTeams } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + const teams = await getUserTeams(auth.user.id, filters); + + return json(teams); +} diff --git a/src/app/api/me/websites/route.ts b/src/app/api/me/websites/route.ts new file mode 100644 index 0000000..9ec39c7 --- /dev/null +++ b/src/app/api/me/websites/route.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json } from '@/lib/response'; +import { pagingParams } from '@/lib/schema'; +import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + includeTeams: z.string().optional(), + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + if (query.includeTeams) { + return json(await getAllUserWebsitesIncludingTeamOwner(auth.user.id, filters)); + } + + return json(await getUserWebsites(auth.user.id, filters)); +} diff --git a/src/app/api/pixels/[pixelId]/route.ts b/src/app/api/pixels/[pixelId]/route.ts new file mode 100644 index 0000000..ecaf1fd --- /dev/null +++ b/src/app/api/pixels/[pixelId]/route.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; +import { canDeletePixel, canUpdatePixel, canViewPixel } from '@/permissions'; +import { deletePixel, getPixel, updatePixel } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ pixelId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { pixelId } = await params; + + if (!(await canViewPixel(auth, pixelId))) { + return unauthorized(); + } + + const pixel = await getPixel(pixelId); + + return json(pixel); +} + +export async function POST(request: Request, { params }: { params: Promise<{ pixelId: string }> }) { + const schema = z.object({ + name: z.string().optional(), + slug: z.string().min(8).optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { pixelId } = await params; + const { name, slug } = body; + + if (!(await canUpdatePixel(auth, pixelId))) { + return unauthorized(); + } + + try { + const pixel = await updatePixel(pixelId, { name, slug }); + + return Response.json(pixel); + } catch (e: any) { + if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('slug')) { + return badRequest({ message: 'That slug is already taken.' }); + } + + return serverError(e); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ pixelId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { pixelId } = await params; + + if (!(await canDeletePixel(auth, pixelId))) { + return unauthorized(); + } + + await deletePixel(pixelId); + + return ok(); +} diff --git a/src/app/api/pixels/route.ts b/src/app/api/pixels/route.ts new file mode 100644 index 0000000..8baae4f --- /dev/null +++ b/src/app/api/pixels/route.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; +import { createPixel, getUserPixels } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + const links = await getUserPixels(auth.user.id, filters); + + return json(links); +} + +export async function POST(request: Request) { + const schema = z.object({ + name: z.string().max(100), + slug: z.string().max(100), + teamId: z.string().nullable().optional(), + id: z.uuid().nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { id, name, slug, teamId } = body; + + if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { + return unauthorized(); + } + + const data: any = { + id: id ?? uuid(), + name, + slug, + teamId, + }; + + if (!teamId) { + data.userId = auth.user.id; + } + + const result = await createPixel(data); + + return json(result); +} diff --git a/src/app/api/realtime/[websiteId]/route.ts b/src/app/api/realtime/[websiteId]/route.ts new file mode 100644 index 0000000..32b7a16 --- /dev/null +++ b/src/app/api/realtime/[websiteId]/route.ts @@ -0,0 +1,36 @@ +import { startOfMinute, subMinutes } from 'date-fns'; +import { REALTIME_RANGE } from '@/lib/constants'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getRealtimeData } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, query, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters( + { + ...query, + startAt: subMinutes(startOfMinute(new Date()), REALTIME_RANGE).getTime(), + endAt: Date.now(), + }, + websiteId, + ); + + const data = await getRealtimeData(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/reports/[reportId]/route.ts b/src/app/api/reports/[reportId]/route.ts new file mode 100644 index 0000000..1f22c62 --- /dev/null +++ b/src/app/api/reports/[reportId]/route.ts @@ -0,0 +1,80 @@ +import { parseRequest } from '@/lib/request'; +import { json, notFound, ok, unauthorized } from '@/lib/response'; +import { reportSchema } from '@/lib/schema'; +import { canDeleteWebsite, canUpdateWebsite, canViewReport } from '@/permissions'; +import { deleteReport, getReport, updateReport } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ reportId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { reportId } = await params; + + const report = await getReport(reportId); + + if (!(await canViewReport(auth, report))) { + return unauthorized(); + } + + return json(report); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ reportId: string }> }, +) { + const { auth, body, error } = await parseRequest(request, reportSchema); + + if (error) { + return error(); + } + + const { reportId } = await params; + const { websiteId, type, name, description, parameters } = body; + + const report = await getReport(reportId); + + if (!report) { + return notFound(); + } + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await updateReport(reportId, { + websiteId, + userId: auth.user.id, + type, + name, + description, + parameters, + } as any); + + return json(result); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ reportId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { reportId } = await params; + const report = await getReport(reportId); + + if (!(await canDeleteWebsite(auth, report.websiteId))) { + return unauthorized(); + } + + await deleteReport(reportId); + + return ok(); +} diff --git a/src/app/api/reports/attribution/route.ts b/src/app/api/reports/attribution/route.ts new file mode 100644 index 0000000..bd7d86d --- /dev/null +++ b/src/app/api/reports/attribution/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { type AttributionParameters, getAttribution } from '@/queries/sql/reports/getAttribution'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const parameters = await setWebsiteDate(websiteId, body.parameters); + const filters = await getQueryFilters(body.filters, websiteId); + + const data = await getAttribution(websiteId, parameters as AttributionParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/breakdown/route.ts b/src/app/api/reports/breakdown/route.ts new file mode 100644 index 0000000..3c59314 --- /dev/null +++ b/src/app/api/reports/breakdown/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { type BreakdownParameters, getBreakdown } from '@/queries/sql'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const parameters = await setWebsiteDate(websiteId, body.parameters); + const filters = await getQueryFilters(body.filters, websiteId); + + const data = await getBreakdown(websiteId, parameters as BreakdownParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/funnel/route.ts b/src/app/api/reports/funnel/route.ts new file mode 100644 index 0000000..c13f6f1 --- /dev/null +++ b/src/app/api/reports/funnel/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { type FunnelParameters, getFunnel } from '@/queries/sql'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const parameters = await setWebsiteDate(websiteId, body.parameters); + const filters = await getQueryFilters(body.filters, websiteId); + + const data = await getFunnel(websiteId, parameters as FunnelParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/goal/route.ts b/src/app/api/reports/goal/route.ts new file mode 100644 index 0000000..3bd0415 --- /dev/null +++ b/src/app/api/reports/goal/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { type GoalParameters, getGoal } from '@/queries/sql/reports/getGoal'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const parameters = await setWebsiteDate(websiteId, body.parameters); + const filters = await getQueryFilters(body.filters, websiteId); + + const data = await getGoal(websiteId, parameters as GoalParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts new file mode 100644 index 0000000..29e8531 --- /dev/null +++ b/src/app/api/reports/journey/route.ts @@ -0,0 +1,25 @@ +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getJourney } from '@/queries/sql'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId, parameters, filters } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const queryFilters = await getQueryFilters(filters, websiteId); + + const data = await getJourney(websiteId, parameters, queryFilters); + + return json(data); +} diff --git a/src/app/api/reports/retention/route.ts b/src/app/api/reports/retention/route.ts new file mode 100644 index 0000000..d1a7d69 --- /dev/null +++ b/src/app/api/reports/retention/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getRetention, type RetentionParameters } from '@/queries/sql'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(body.filters, websiteId); + const parameters = await setWebsiteDate(websiteId, body.parameters); + + const data = await getRetention(websiteId, parameters as RetentionParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/revenue/route.ts b/src/app/api/reports/revenue/route.ts new file mode 100644 index 0000000..6a55661 --- /dev/null +++ b/src/app/api/reports/revenue/route.ts @@ -0,0 +1,26 @@ +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getRevenue, type RevenuParameters } from '@/queries/sql/reports/getRevenue'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const parameters = await setWebsiteDate(websiteId, body.parameters); + const filters = await getQueryFilters(body.filters, websiteId); + + const data = await getRevenue(websiteId, parameters as RevenuParameters, filters); + + return json(data); +} diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts new file mode 100644 index 0000000..b0a4135 --- /dev/null +++ b/src/app/api/reports/route.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, reportSchema, reportTypeParam } from '@/lib/schema'; +import { canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { createReport, getReports } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + websiteId: z.uuid(), + type: reportTypeParam.optional(), + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { page, search, pageSize, websiteId, type } = query; + const filters = { + page, + pageSize, + search, + }; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getReports( + { + where: { + websiteId, + type, + website: { + deletedAt: null, + }, + }, + }, + filters, + ); + + return json(data); +} + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportSchema); + + if (error) { + return error(); + } + + const { websiteId, type, name, description, parameters } = body; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await createReport({ + id: uuid(), + userId: auth.user.id, + websiteId, + type, + name, + description: description || '', + parameters, + }); + + return json(result); +} diff --git a/src/app/api/reports/utm/route.ts b/src/app/api/reports/utm/route.ts new file mode 100644 index 0000000..577fdab --- /dev/null +++ b/src/app/api/reports/utm/route.ts @@ -0,0 +1,37 @@ +import { UTM_PARAMS } from '@/lib/constants'; +import { getQueryFilters, parseRequest, setWebsiteDate } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { reportResultSchema } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getUTM, type UTMParameters } from '@/queries/sql'; + +export async function POST(request: Request) { + const { auth, body, error } = await parseRequest(request, reportResultSchema); + + if (error) { + return error(); + } + + const { websiteId } = body; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(body.filters, websiteId); + const parameters = await setWebsiteDate(websiteId, body.parameters); + + const data = { + utm_source: [], + utm_medium: [], + utm_campaign: [], + utm_term: [], + utm_content: [], + }; + + for (const key of UTM_PARAMS) { + data[key] = await getUTM(websiteId, { column: key, ...parameters } as UTMParameters, filters); + } + + return json(data); +} diff --git a/src/app/api/scripts/telemetry/route.ts b/src/app/api/scripts/telemetry/route.ts new file mode 100644 index 0000000..b19e99f --- /dev/null +++ b/src/app/api/scripts/telemetry/route.ts @@ -0,0 +1,28 @@ +import { CURRENT_VERSION, TELEMETRY_PIXEL } from '@/lib/constants'; + +export async function GET() { + if ( + process.env.NODE_ENV !== 'production' || + process.env.DISABLE_TELEMETRY || + process.env.PRIVATE_MODE + ) { + return new Response('/* telemetry disabled */', { + headers: { + 'content-type': 'text/javascript', + }, + }); + } + + const script = ` + (()=>{const i=document.createElement('img'); + i.setAttribute('src','${TELEMETRY_PIXEL}?v=${CURRENT_VERSION}'); + i.setAttribute('style','width:0;height:0;position:absolute;pointer-events:none;'); + document.body.appendChild(i);})(); + `; + + return new Response(script.replace(/\s\s+/g, ''), { + headers: { + 'content-type': 'text/javascript', + }, + }); +} diff --git a/src/app/api/send/route.ts b/src/app/api/send/route.ts new file mode 100644 index 0000000..a0becc2 --- /dev/null +++ b/src/app/api/send/route.ts @@ -0,0 +1,284 @@ +import { startOfHour, startOfMonth } from 'date-fns'; +import { isbot } from 'isbot'; +import { serializeError } from 'serialize-error'; +import { z } from 'zod'; +import clickhouse from '@/lib/clickhouse'; +import { COLLECTION_TYPE, EVENT_TYPE } from '@/lib/constants'; +import { hash, secret, uuid } from '@/lib/crypto'; +import { getClientInfo, hasBlockedIp } from '@/lib/detect'; +import { createToken, parseToken } from '@/lib/jwt'; +import { fetchWebsite } from '@/lib/load'; +import { parseRequest } from '@/lib/request'; +import { badRequest, forbidden, json, serverError } from '@/lib/response'; +import { anyObjectParam, urlOrPathParam } from '@/lib/schema'; +import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url'; +import { createSession, saveEvent, saveSessionData } from '@/queries/sql'; + +interface Cache { + websiteId: string; + sessionId: string; + visitId: string; + iat: number; +} + +const schema = z.object({ + type: z.enum(['event', 'identify']), + payload: z + .object({ + website: z.uuid().optional(), + link: z.uuid().optional(), + pixel: z.uuid().optional(), + data: anyObjectParam.optional(), + hostname: z.string().max(100).optional(), + language: z.string().max(35).optional(), + referrer: urlOrPathParam.optional(), + screen: z.string().max(11).optional(), + title: z.string().optional(), + url: urlOrPathParam.optional(), + name: z.string().max(50).optional(), + tag: z.string().max(50).optional(), + ip: z.string().optional(), + userAgent: z.string().optional(), + timestamp: z.coerce.number().int().optional(), + id: z.string().optional(), + browser: z.string().optional(), + os: z.string().optional(), + device: z.string().optional(), + }) + .refine( + data => { + const keys = [data.website, data.link, data.pixel]; + const count = keys.filter(Boolean).length; + return count === 1; + }, + { + message: 'Exactly one of website, link, or pixel must be provided', + path: ['website'], + }, + ), +}); + +export async function POST(request: Request) { + try { + const { body, error } = await parseRequest(request, schema, { skipAuth: true }); + + if (error) { + return error(); + } + + const { type, payload } = body; + + const { + website: websiteId, + pixel: pixelId, + link: linkId, + hostname, + screen, + language, + url, + referrer, + name, + data, + title, + tag, + timestamp, + id, + } = payload; + + const sourceId = websiteId || pixelId || linkId; + + // Cache check + let cache: Cache | null = null; + + if (websiteId) { + const cacheHeader = request.headers.get('x-umami-cache'); + + if (cacheHeader) { + const result = await parseToken(cacheHeader, secret()); + + if (result) { + cache = result; + } + } + + // Find website + if (!cache?.websiteId) { + const website = await fetchWebsite(websiteId); + + if (!website) { + return badRequest({ message: 'Website not found.' }); + } + } + } + + // Client info + const { ip, userAgent, device, browser, os, country, region, city } = await getClientInfo( + request, + payload, + ); + + // Bot check + if (!process.env.DISABLE_BOT_CHECK && isbot(userAgent)) { + return json({ beep: 'boop' }); + } + + // IP block + if (hasBlockedIp(ip)) { + return forbidden(); + } + + const createdAt = timestamp ? new Date(timestamp * 1000) : new Date(); + const now = Math.floor(Date.now() / 1000); + + const sessionSalt = hash(startOfMonth(createdAt).toUTCString()); + const visitSalt = hash(startOfHour(createdAt).toUTCString()); + + const sessionId = id ? uuid(sourceId, id) : uuid(sourceId, ip, userAgent, sessionSalt); + + // Create a session if not found + if (!clickhouse.enabled && !cache?.sessionId) { + await createSession({ + id: sessionId, + websiteId: sourceId, + browser, + os, + device, + screen, + language, + country, + region, + city, + distinctId: id, + createdAt, + }); + } + + // Visit info + let visitId = cache?.visitId || uuid(sessionId, visitSalt); + let iat = cache?.iat || now; + + // Expire visit after 30 minutes + if (!timestamp && now - iat > 1800) { + visitId = uuid(sessionId, visitSalt); + iat = now; + } + + if (type === COLLECTION_TYPE.event) { + const base = hostname ? `https://${hostname}` : 'https://localhost'; + const currentUrl = new URL(url, base); + + let urlPath = + currentUrl.pathname === '/undefined' ? '' : currentUrl.pathname + currentUrl.hash; + const urlQuery = currentUrl.search.substring(1); + const urlDomain = currentUrl.hostname.replace(/^www./, ''); + + let referrerPath: string; + let referrerQuery: string; + let referrerDomain: string; + + // UTM Params + const utmSource = currentUrl.searchParams.get('utm_source'); + const utmMedium = currentUrl.searchParams.get('utm_medium'); + const utmCampaign = currentUrl.searchParams.get('utm_campaign'); + const utmContent = currentUrl.searchParams.get('utm_content'); + const utmTerm = currentUrl.searchParams.get('utm_term'); + + // Click IDs + const gclid = currentUrl.searchParams.get('gclid'); + const fbclid = currentUrl.searchParams.get('fbclid'); + const msclkid = currentUrl.searchParams.get('msclkid'); + const ttclid = currentUrl.searchParams.get('ttclid'); + const lifatid = currentUrl.searchParams.get('li_fat_id'); + const twclid = currentUrl.searchParams.get('twclid'); + + if (process.env.REMOVE_TRAILING_SLASH) { + urlPath = urlPath.replace(/\/(?=(#.*)?$)/, ''); + } + + if (referrer) { + const referrerUrl = new URL(referrer, base); + + referrerPath = referrerUrl.pathname; + referrerQuery = referrerUrl.search.substring(1); + referrerDomain = referrerUrl.hostname.replace(/^www\./, ''); + } + + const eventType = linkId + ? EVENT_TYPE.linkEvent + : pixelId + ? EVENT_TYPE.pixelEvent + : name + ? EVENT_TYPE.customEvent + : EVENT_TYPE.pageView; + + await saveEvent({ + websiteId: sourceId, + sessionId, + visitId, + eventType, + createdAt, + + // Page + pageTitle: safeDecodeURIComponent(title), + hostname: hostname || urlDomain, + urlPath: safeDecodeURI(urlPath), + urlQuery, + referrerPath: safeDecodeURI(referrerPath), + referrerQuery, + referrerDomain, + + // Session + distinctId: id, + browser, + os, + device, + screen, + language, + country, + region, + city, + + // Events + eventName: name, + eventData: data, + tag, + + // UTM + utmSource, + utmMedium, + utmCampaign, + utmContent, + utmTerm, + + // Click IDs + gclid, + fbclid, + msclkid, + ttclid, + lifatid, + twclid, + }); + } else if (type === COLLECTION_TYPE.identify) { + if (data) { + await saveSessionData({ + websiteId, + sessionId, + sessionData: data, + distinctId: id, + createdAt, + }); + } + } + + const token = createToken({ websiteId, sessionId, visitId, iat }, secret()); + + return json({ cache: token, sessionId, visitId }); + } catch (e) { + const error = serializeError(e); + + // eslint-disable-next-line no-console + console.log(error); + + return serverError({ errorObject: error }); + } +} diff --git a/src/app/api/share/[shareId]/route.ts b/src/app/api/share/[shareId]/route.ts new file mode 100644 index 0000000..bef87c4 --- /dev/null +++ b/src/app/api/share/[shareId]/route.ts @@ -0,0 +1,19 @@ +import { secret } from '@/lib/crypto'; +import { createToken } from '@/lib/jwt'; +import { json, notFound } from '@/lib/response'; +import { getSharedWebsite } from '@/queries/prisma'; + +export async function GET(_request: Request, { params }: { params: Promise<{ shareId: string }> }) { + const { shareId } = await params; + + const website = await getSharedWebsite(shareId); + + if (!website) { + return notFound(); + } + + const data = { websiteId: website.id }; + const token = createToken(data, secret()); + + return json({ ...data, token }); +} diff --git a/src/app/api/teams/[teamId]/links/route.ts b/src/app/api/teams/[teamId]/links/route.ts new file mode 100644 index 0000000..41e139b --- /dev/null +++ b/src/app/api/teams/[teamId]/links/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewTeam } from '@/permissions'; +import { getTeamLinks } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + const { teamId } = await params; + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const links = await getTeamLinks(teamId, filters); + + return json(links); +} diff --git a/src/app/api/teams/[teamId]/pixels/route.ts b/src/app/api/teams/[teamId]/pixels/route.ts new file mode 100644 index 0000000..daac204 --- /dev/null +++ b/src/app/api/teams/[teamId]/pixels/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewTeam } from '@/permissions'; +import { getTeamPixels } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + const { teamId } = await params; + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const websites = await getTeamPixels(teamId, filters); + + return json(websites); +} diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts new file mode 100644 index 0000000..c334b2a --- /dev/null +++ b/src/app/api/teams/[teamId]/route.ts @@ -0,0 +1,71 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, notFound, ok, unauthorized } from '@/lib/response'; +import { canDeleteTeam, canUpdateTeam, canViewTeam } from '@/permissions'; +import { deleteTeam, getTeam, updateTeam } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const team = await getTeam(teamId, { includeMembers: true }); + + if (!team) { + return notFound({ message: 'Team not found.' }); + } + + return json(team); +} + +export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + name: z.string().max(50).optional(), + accessCode: z.string().max(50).optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canUpdateTeam(auth, teamId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + const team = await updateTeam(teamId, body); + + return json(team); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ teamId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canDeleteTeam(auth, teamId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + await deleteTeam(teamId); + + return ok(); +} diff --git a/src/app/api/teams/[teamId]/users/[userId]/route.ts b/src/app/api/teams/[teamId]/users/[userId]/route.ts new file mode 100644 index 0000000..d09af9d --- /dev/null +++ b/src/app/api/teams/[teamId]/users/[userId]/route.ts @@ -0,0 +1,85 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, unauthorized } from '@/lib/response'; +import { teamRoleParam } from '@/lib/schema'; +import { canDeleteTeamUser, canUpdateTeam } from '@/permissions'; +import { deleteTeamUser, getTeamUser, updateTeamUser } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ teamId: string; userId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { teamId, userId } = await params; + + if (!(await canUpdateTeam(auth, teamId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + const teamUser = await getTeamUser(teamId, userId); + + return json(teamUser); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ teamId: string; userId: string }> }, +) { + const schema = z.object({ + role: teamRoleParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { teamId, userId } = await params; + + if (!(await canUpdateTeam(auth, teamId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + const teamUser = await getTeamUser(teamId, userId); + + if (!teamUser) { + return badRequest({ message: 'The User does not exists on this team.' }); + } + + const user = await updateTeamUser(teamUser.id, body); + + return json(user); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ teamId: string; userId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { teamId, userId } = await params; + + if (!(await canDeleteTeamUser(auth, teamId, userId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + const teamUser = await getTeamUser(teamId, userId); + + if (!teamUser) { + return badRequest({ message: 'The User does not exists on this team.' }); + } + + await deleteTeamUser(teamId, userId); + + return ok(); +} diff --git a/src/app/api/teams/[teamId]/users/route.ts b/src/app/api/teams/[teamId]/users/route.ts new file mode 100644 index 0000000..c129763 --- /dev/null +++ b/src/app/api/teams/[teamId]/users/route.ts @@ -0,0 +1,83 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams, teamRoleParam } from '@/lib/schema'; +import { canUpdateTeam, canViewTeam } from '@/permissions'; +import { createTeamUser, getTeamUser, getTeamUsers } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized({ message: 'You must be a member of this team.' }); + } + + const filters = await getQueryFilters(query); + + const users = await getTeamUsers( + { + where: { + teamId, + user: { + deletedAt: null, + }, + }, + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }, + filters, + ); + + return json(users); +} + +export async function POST(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + userId: z.uuid(), + role: teamRoleParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { teamId } = await params; + + if (!(await canUpdateTeam(auth, teamId))) { + return unauthorized({ message: 'You must be the owner/manager of this team.' }); + } + + const { userId, role } = body; + + const teamUser = await getTeamUser(teamId, userId); + + if (teamUser) { + return badRequest({ message: 'User is already a member of the Team.' }); + } + + const users = await createTeamUser(userId, teamId, role); + + return json(users); +} diff --git a/src/app/api/teams/[teamId]/websites/route.ts b/src/app/api/teams/[teamId]/websites/route.ts new file mode 100644 index 0000000..05c6d80 --- /dev/null +++ b/src/app/api/teams/[teamId]/websites/route.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canViewTeam } from '@/permissions'; +import { getTeamWebsites } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ teamId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + }); + const { teamId } = await params; + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canViewTeam(auth, teamId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const websites = await getTeamWebsites(teamId, filters); + + return json(websites); +} diff --git a/src/app/api/teams/join/route.ts b/src/app/api/teams/join/route.ts new file mode 100644 index 0000000..3ce0913 --- /dev/null +++ b/src/app/api/teams/join/route.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { ROLES } from '@/lib/constants'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, notFound } from '@/lib/response'; +import { createTeamUser, findTeam, getTeamUser } from '@/queries/prisma'; + +export async function POST(request: Request) { + const schema = z.object({ + accessCode: z.string().max(50), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { accessCode } = body; + + const team = await findTeam({ + where: { + accessCode, + }, + }); + + if (!team) { + return notFound({ message: 'Team not found.', code: 'team-not-found' }); + } + + const teamUser = await getTeamUser(team.id, auth.user.id); + + if (teamUser) { + return badRequest({ message: 'User is already a team member.' }); + } + + const user = await createTeamUser(auth.user.id, team.id, ROLES.teamMember); + + return json(user); +} diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts new file mode 100644 index 0000000..53ef592 --- /dev/null +++ b/src/app/api/teams/route.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { getRandomChars } from '@/lib/generate'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams } from '@/lib/schema'; +import { canCreateTeam } from '@/permissions'; +import { createTeam, getUserTeams } from '@/queries/prisma'; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const filters = await getQueryFilters(query); + + const teams = await getUserTeams(auth.user.id, filters); + + return json(teams); +} + +export async function POST(request: Request) { + const schema = z.object({ + name: z.string().max(50), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canCreateTeam(auth))) { + return unauthorized(); + } + + const { name } = body; + + const team = await createTeam( + { + id: uuid(), + name, + accessCode: `team_${getRandomChars(16)}`, + }, + auth.user.id, + ); + + return json(team); +} diff --git a/src/app/api/users/[userId]/route.ts b/src/app/api/users/[userId]/route.ts new file mode 100644 index 0000000..aade8aa --- /dev/null +++ b/src/app/api/users/[userId]/route.ts @@ -0,0 +1,102 @@ +import { z } from 'zod'; +import { hashPassword } from '@/lib/password'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, unauthorized } from '@/lib/response'; +import { userRoleParam } from '@/lib/schema'; +import { canDeleteUser, canUpdateUser, canViewUser } from '@/permissions'; +import { deleteUser, getUser, getUserByUsername, updateUser } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { userId } = await params; + + if (!(await canViewUser(auth, userId))) { + return unauthorized(); + } + + const user = await getUser(userId); + + return json(user); +} + +export async function POST(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const schema = z.object({ + username: z.string().max(255).optional(), + password: z.string().max(255).optional(), + role: userRoleParam.optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { userId } = await params; + + if (!(await canUpdateUser(auth, userId))) { + return unauthorized(); + } + + const { username, password, role } = body; + + const user = await getUser(userId); + + const data: any = {}; + + if (password) { + data.password = hashPassword(password); + } + + // Only admin can change these fields + if (role && auth.user.isAdmin) { + data.role = role; + } + + if (username && auth.user.isAdmin) { + data.username = username; + } + + // Check when username changes + if (data.username && user.username !== data.username) { + const user = await getUserByUsername(username); + + if (user) { + return badRequest({ message: 'User already exists' }); + } + } + + const updated = await updateUser(userId, data); + + return json(updated); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ userId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { userId } = await params; + + if (!(await canDeleteUser(auth))) { + return unauthorized(); + } + + if (userId === auth.user.id) { + return badRequest({ message: 'You cannot delete yourself.' }); + } + + await deleteUser(userId); + + return ok(); +} diff --git a/src/app/api/users/[userId]/teams/route.ts b/src/app/api/users/[userId]/teams/route.ts new file mode 100644 index 0000000..7a834a3 --- /dev/null +++ b/src/app/api/users/[userId]/teams/route.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams } from '@/lib/schema'; +import { getUserTeams } from '@/queries/prisma'; + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const schema = z.object({ + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { userId } = await params; + + if (auth.user.id !== userId && !auth.user.isAdmin) { + return unauthorized(); + } + + const teams = await getUserTeams(userId, query); + + return json(teams); +} diff --git a/src/app/api/users/[userId]/websites/route.ts b/src/app/api/users/[userId]/websites/route.ts new file mode 100644 index 0000000..1107d8e --- /dev/null +++ b/src/app/api/users/[userId]/websites/route.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website'; + +export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + includeTeams: z.string().optional(), + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { userId } = await params; + + if (!auth.user.isAdmin && auth.user.id !== userId) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + if (query.includeTeams) { + return json(await getAllUserWebsitesIncludingTeamOwner(userId, filters)); + } + + return json(await getUserWebsites(userId, filters)); +} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..dbb114c --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,44 @@ +import { z } from 'zod'; +import { ROLES } from '@/lib/constants'; +import { uuid } from '@/lib/crypto'; +import { hashPassword } from '@/lib/password'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { canCreateUser } from '@/permissions'; +import { createUser, getUserByUsername } from '@/queries/prisma'; + +export async function POST(request: Request) { + const schema = z.object({ + id: z.uuid().optional(), + username: z.string().max(255), + password: z.string(), + role: z.string().regex(/admin|user|view-only/i), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + if (!(await canCreateUser(auth))) { + return unauthorized(); + } + + const { id, username, password, role } = body; + + const existingUser = await getUserByUsername(username, { showDeleted: true }); + + if (existingUser) { + return badRequest({ message: 'User already exists' }); + } + + const user = await createUser({ + id: id || uuid(), + username, + password: hashPassword(password), + role: role ?? ROLES.user, + }); + + return json(user); +} diff --git a/src/app/api/websites/[websiteId]/active/route.ts b/src/app/api/websites/[websiteId]/active/route.ts new file mode 100644 index 0000000..233b97e --- /dev/null +++ b/src/app/api/websites/[websiteId]/active/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getActiveVisitors } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const visitors = await getActiveVisitors(websiteId); + + return json(visitors); +} diff --git a/src/app/api/websites/[websiteId]/daterange/route.ts b/src/app/api/websites/[websiteId]/daterange/route.ts new file mode 100644 index 0000000..14a241f --- /dev/null +++ b/src/app/api/websites/[websiteId]/daterange/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteDateRange } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const dateRange = await getWebsiteDateRange(websiteId); + + return json(dateRange); +} diff --git a/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts b/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts new file mode 100644 index 0000000..54afab2 --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/[eventId]/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getEventData } from '@/queries/sql/events/getEventData'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; eventId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, eventId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getEventData(websiteId, eventId); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/event-data/events/route.ts b/src/app/api/websites/[websiteId]/event-data/events/route.ts new file mode 100644 index 0000000..eb6ee6e --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/events/route.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventDataEvents } from '@/queries/sql/events/getEventDataEvents'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + event: z.string().optional(), + ...filterParams, + }); + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventDataEvents(websiteId, { + ...filters, + }); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/event-data/fields/route.ts b/src/app/api/websites/[websiteId]/event-data/fields/route.ts new file mode 100644 index 0000000..bce6a97 --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/fields/route.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventDataFields } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventDataFields(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/event-data/properties/route.ts b/src/app/api/websites/[websiteId]/event-data/properties/route.ts new file mode 100644 index 0000000..52d15cf --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/properties/route.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventDataProperties } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventDataProperties(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/event-data/stats/route.ts b/src/app/api/websites/[websiteId]/event-data/stats/route.ts new file mode 100644 index 0000000..042e989 --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/stats/route.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventDataStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventDataStats(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/event-data/values/route.ts b/src/app/api/websites/[websiteId]/event-data/values/route.ts new file mode 100644 index 0000000..12e8f2d --- /dev/null +++ b/src/app/api/websites/[websiteId]/event-data/values/route.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventDataValues } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + event: z.string(), + propertyName: z.string(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName } = query; + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventDataValues(websiteId, { + ...filters, + propertyName, + }); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/events/route.ts b/src/app/api/websites/[websiteId]/events/route.ts new file mode 100644 index 0000000..74ec3ec --- /dev/null +++ b/src/app/api/websites/[websiteId]/events/route.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, pagingParams, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteEvents } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().optional(), + endAt: z.coerce.number().optional(), + ...filterParams, + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getWebsiteEvents(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/events/series/route.ts b/src/app/api/websites/[websiteId]/events/series/route.ts new file mode 100644 index 0000000..977e9c8 --- /dev/null +++ b/src/app/api/websites/[websiteId]/events/series/route.ts @@ -0,0 +1,37 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam, unitParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + unit: unitParam.optional(), + timezone: timezoneParam, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getEventStats(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/export/route.ts b/src/app/api/websites/[websiteId]/export/route.ts new file mode 100644 index 0000000..eec81c6 --- /dev/null +++ b/src/app/api/websites/[websiteId]/export/route.ts @@ -0,0 +1,64 @@ +import JSZip from 'jszip'; +import Papa from 'papaparse'; +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, pagingParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getEventMetrics, getPageviewMetrics, getSessionMetrics } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const [events, pages, referrers, browsers, os, devices, countries] = await Promise.all([ + getEventMetrics(websiteId, { type: 'event' }, filters), + getPageviewMetrics(websiteId, { type: 'path' }, filters), + getPageviewMetrics(websiteId, { type: 'referrer' }, filters), + getSessionMetrics(websiteId, { type: 'browser' }, filters), + getSessionMetrics(websiteId, { type: 'os' }, filters), + getSessionMetrics(websiteId, { type: 'device' }, filters), + getSessionMetrics(websiteId, { type: 'country' }, filters), + ]); + + const zip = new JSZip(); + + const parse = (data: any) => { + return Papa.unparse(data, { + header: true, + skipEmptyLines: true, + }); + }; + + zip.file('events.csv', parse(events)); + zip.file('pages.csv', parse(pages)); + zip.file('referrers.csv', parse(referrers)); + zip.file('browsers.csv', parse(browsers)); + zip.file('os.csv', parse(os)); + zip.file('devices.csv', parse(devices)); + zip.file('countries.csv', parse(countries)); + + const content = await zip.generateAsync({ type: 'nodebuffer' }); + const base64 = content.toString('base64'); + + return json({ zip: base64 }); +} diff --git a/src/app/api/websites/[websiteId]/metrics/expanded/route.ts b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts new file mode 100644 index 0000000..d52c177 --- /dev/null +++ b/src/app/api/websites/[websiteId]/metrics/expanded/route.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { EVENT_COLUMNS, EVENT_TYPE, SESSION_COLUMNS } from '@/lib/constants'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { + getChannelExpandedMetrics, + getEventExpandedMetrics, + getPageviewExpandedMetrics, + getSessionExpandedMetrics, +} from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: z.string(), + limit: z.coerce.number().optional(), + offset: z.coerce.number().optional(), + ...dateRangeParams, + ...searchParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { type, limit, offset, search } = query; + const filters = await getQueryFilters(query, websiteId); + + if (search) { + filters[type] = `c.${search}`; + } + + if (SESSION_COLUMNS.includes(type)) { + const data = await getSessionExpandedMetrics(websiteId, { type, limit, offset }, filters); + + return json(data); + } + + if (EVENT_COLUMNS.includes(type)) { + if (type === 'event') { + filters.eventType = EVENT_TYPE.customEvent; + return json(await getEventExpandedMetrics(websiteId, { type, limit, offset }, filters)); + } else { + return json(await getPageviewExpandedMetrics(websiteId, { type, limit, offset }, filters)); + } + } + + if (type === 'channel') { + return json(await getChannelExpandedMetrics(websiteId, filters)); + } + + return badRequest(); +} diff --git a/src/app/api/websites/[websiteId]/metrics/route.ts b/src/app/api/websites/[websiteId]/metrics/route.ts new file mode 100644 index 0000000..12784ad --- /dev/null +++ b/src/app/api/websites/[websiteId]/metrics/route.ts @@ -0,0 +1,66 @@ +import { z } from 'zod'; +import { EVENT_COLUMNS, EVENT_TYPE, SESSION_COLUMNS } from '@/lib/constants'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { + getChannelMetrics, + getEventMetrics, + getPageviewMetrics, + getSessionMetrics, +} from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: z.string(), + limit: z.coerce.number().optional(), + offset: z.coerce.number().optional(), + ...dateRangeParams, + ...searchParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { type, limit, offset, search } = query; + const filters = await getQueryFilters(query, websiteId); + + if (search) { + filters[type] = `c.${search}`; + } + + if (SESSION_COLUMNS.includes(type)) { + const data = await getSessionMetrics(websiteId, { type, limit, offset }, filters); + + return json(data); + } + + if (EVENT_COLUMNS.includes(type)) { + if (type === 'event') { + filters.eventType = EVENT_TYPE.customEvent; + return json(await getEventMetrics(websiteId, { type, limit, offset }, filters)); + } else { + return json(await getPageviewMetrics(websiteId, { type, limit, offset }, filters)); + } + } + + if (type === 'channel') { + return json(await getChannelMetrics(websiteId, filters)); + } + + return badRequest(); +} diff --git a/src/app/api/websites/[websiteId]/pageviews/route.ts b/src/app/api/websites/[websiteId]/pageviews/route.ts new file mode 100644 index 0000000..af59bce --- /dev/null +++ b/src/app/api/websites/[websiteId]/pageviews/route.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; +import { getCompareDate } from '@/lib/date'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getPageviewStats, getSessionStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const [pageviews, sessions] = await Promise.all([ + getPageviewStats(websiteId, filters), + getSessionStats(websiteId, filters), + ]); + + if (filters.compare) { + const { startDate: compareStartDate, endDate: compareEndDate } = getCompareDate( + filters.compare, + filters.startDate, + filters.endDate, + ); + + const [comparePageviews, compareSessions] = await Promise.all([ + getPageviewStats(websiteId, { + ...filters, + startDate: compareStartDate, + endDate: compareEndDate, + }), + getSessionStats(websiteId, { + ...filters, + startDate: compareStartDate, + endDate: compareEndDate, + }), + ]); + + return json({ + pageviews, + sessions, + startDate: filters.startDate, + endDate: filters.endDate, + compare: { + pageviews: comparePageviews, + sessions: compareSessions, + startDate: compareStartDate, + endDate: compareEndDate, + }, + }); + } + + return json({ pageviews, sessions }); +} diff --git a/src/app/api/websites/[websiteId]/reports/route.ts b/src/app/api/websites/[websiteId]/reports/route.ts new file mode 100644 index 0000000..93e7ab4 --- /dev/null +++ b/src/app/api/websites/[websiteId]/reports/route.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, pagingParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getReports } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, + filters: { type: string }, +) { + const schema = z.object({ + ...filterParams, + ...pagingParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { page, pageSize, search } = query; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getReports( + { + where: { + websiteId, + type: filters.type, + }, + }, + { + page, + pageSize, + search, + }, + ); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/reset/route.ts b/src/app/api/websites/[websiteId]/reset/route.ts new file mode 100644 index 0000000..e0be5a5 --- /dev/null +++ b/src/app/api/websites/[websiteId]/reset/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { ok, unauthorized } from '@/lib/response'; +import { canUpdateWebsite } from '@/permissions'; +import { resetWebsite } from '@/queries/prisma'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + await resetWebsite(websiteId); + + return ok(); +} diff --git a/src/app/api/websites/[websiteId]/route.ts b/src/app/api/websites/[websiteId]/route.ts new file mode 100644 index 0000000..b4c0e7e --- /dev/null +++ b/src/app/api/websites/[websiteId]/route.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; +import { SHARE_ID_REGEX } from '@/lib/constants'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, ok, serverError, unauthorized } from '@/lib/response'; +import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { deleteWebsite, getWebsite, updateWebsite } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const website = await getWebsite(websiteId); + + return json(website); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + name: z.string().optional(), + domain: z.string().optional(), + shareId: z.string().regex(SHARE_ID_REGEX).nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { name, domain, shareId } = body; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + try { + const website = await updateWebsite(websiteId, { name, domain, shareId }); + + return Response.json(website); + } catch (e: any) { + if (e.message.toLowerCase().includes('unique constraint') && e.message.includes('share_id')) { + return badRequest({ message: 'That share ID is already taken.' }); + } + + return serverError(e); + } +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canDeleteWebsite(auth, websiteId))) { + return unauthorized(); + } + + await deleteWebsite(websiteId); + + return ok(); +} diff --git a/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts b/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts new file mode 100644 index 0000000..b51f783 --- /dev/null +++ b/src/app/api/websites/[websiteId]/segments/[segmentId]/route.ts @@ -0,0 +1,92 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { json, notFound, ok, unauthorized } from '@/lib/response'; +import { anyObjectParam, segmentTypeParam } from '@/lib/schema'; +import { canDeleteWebsite, canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { deleteSegment, getSegment, updateSegment } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; segmentId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, segmentId } = await params; + + const segment = await getSegment(segmentId); + + if (websiteId && !(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + return json(segment); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string; segmentId: string }> }, +) { + const schema = z.object({ + type: segmentTypeParam, + name: z.string().max(200), + parameters: anyObjectParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId, segmentId } = await params; + const { type, name, parameters } = body; + + const segment = await getSegment(segmentId); + + if (!segment) { + return notFound(); + } + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await updateSegment(segmentId, { + type, + name, + parameters, + } as any); + + return json(result); +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ websiteId: string; segmentId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, segmentId } = await params; + + const segment = await getSegment(segmentId); + + if (!segment) { + return notFound(); + } + + if (!(await canDeleteWebsite(auth, websiteId))) { + return unauthorized(); + } + + await deleteSegment(segmentId); + + return ok(); +} diff --git a/src/app/api/websites/[websiteId]/segments/route.ts b/src/app/api/websites/[websiteId]/segments/route.ts new file mode 100644 index 0000000..4592765 --- /dev/null +++ b/src/app/api/websites/[websiteId]/segments/route.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { anyObjectParam, searchParams, segmentTypeParam } from '@/lib/schema'; +import { canUpdateWebsite, canViewWebsite } from '@/permissions'; +import { createSegment, getWebsiteSegments } from '@/queries/prisma'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: segmentTypeParam, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { type } = query; + + if (websiteId && !(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query); + + const segments = await getWebsiteSegments(websiteId, type, filters); + + return json(segments); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: segmentTypeParam, + name: z.string().max(200), + parameters: anyObjectParam, + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { type, name, parameters } = body; + + if (!(await canUpdateWebsite(auth, websiteId))) { + return unauthorized(); + } + + const result = await createSegment({ + id: uuid(), + websiteId, + type, + name, + parameters, + } as any); + + return json(result); +} diff --git a/src/app/api/websites/[websiteId]/session-data/properties/route.ts b/src/app/api/websites/[websiteId]/session-data/properties/route.ts new file mode 100644 index 0000000..2d8db15 --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data/properties/route.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataProperties } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getSessionDataProperties(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/session-data/values/route.ts b/src/app/api/websites/[websiteId]/session-data/values/route.ts new file mode 100644 index 0000000..7d06870 --- /dev/null +++ b/src/app/api/websites/[websiteId]/session-data/values/route.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getSessionDataValues } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + propertyName: z.string().optional(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { propertyName } = query; + const filters = await getQueryFilters(query, websiteId); + + const data = await getSessionDataValues(websiteId, { + ...filters, + propertyName, + }); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts new file mode 100644 index 0000000..41b766d --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/activity/route.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getSessionActivity } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; sessionId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId, sessionId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getSessionActivity(websiteId, sessionId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts new file mode 100644 index 0000000..6b5c241 --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/properties/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getSessionData } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; sessionId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, sessionId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getSessionData(websiteId, sessionId); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts new file mode 100644 index 0000000..1091663 --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/[sessionId]/route.ts @@ -0,0 +1,25 @@ +import { parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteSession } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string; sessionId: string }> }, +) { + const { auth, error } = await parseRequest(request); + + if (error) { + return error(); + } + + const { websiteId, sessionId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const data = await getWebsiteSession(websiteId, sessionId); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/route.ts b/src/app/api/websites/[websiteId]/sessions/route.ts new file mode 100644 index 0000000..ed4757a --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/route.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams, pagingParams, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteSessions } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...filterParams, + ...pagingParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getWebsiteSessions(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/stats/route.ts b/src/app/api/websites/[websiteId]/sessions/stats/route.ts new file mode 100644 index 0000000..459830e --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/stats/route.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteSessionStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const metrics = await getWebsiteSessionStats(websiteId, filters); + + const data = Object.keys(metrics[0]).reduce((obj, key) => { + obj[key] = { + value: Number(metrics[0][key]) || 0, + }; + return obj; + }, {}); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/sessions/weekly/route.ts b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts new file mode 100644 index 0000000..b9ccf3e --- /dev/null +++ b/src/app/api/websites/[websiteId]/sessions/weekly/route.ts @@ -0,0 +1,36 @@ +import { z } from 'zod'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { filterParams, timezoneParam } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWeeklyTraffic } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + startAt: z.coerce.number().int(), + endAt: z.coerce.number().int(), + timezone: timezoneParam, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getWeeklyTraffic(websiteId, filters); + + return json(data); +} diff --git a/src/app/api/websites/[websiteId]/stats/route.ts b/src/app/api/websites/[websiteId]/stats/route.ts new file mode 100644 index 0000000..07c8b96 --- /dev/null +++ b/src/app/api/websites/[websiteId]/stats/route.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import { getCompareDate } from '@/lib/date'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { dateRangeParams, filterParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteStats } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + ...dateRangeParams, + ...filterParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const filters = await getQueryFilters(query, websiteId); + + const data = await getWebsiteStats(websiteId, filters); + + const { startDate, endDate } = getCompareDate('prev', filters.startDate, filters.endDate); + + const comparison = await getWebsiteStats(websiteId, { + ...filters, + startDate, + endDate, + }); + + return json({ ...data, comparison }); +} diff --git a/src/app/api/websites/[websiteId]/transfer/route.ts b/src/app/api/websites/[websiteId]/transfer/route.ts new file mode 100644 index 0000000..df2fed2 --- /dev/null +++ b/src/app/api/websites/[websiteId]/transfer/route.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { canTransferWebsiteToTeam, canTransferWebsiteToUser } from '@/permissions'; +import { updateWebsite } from '@/queries/prisma'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + userId: z.uuid().optional(), + teamId: z.uuid().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + const { userId, teamId } = body; + + if (userId) { + if (!(await canTransferWebsiteToUser(auth, websiteId, userId))) { + return unauthorized(); + } + + const website = await updateWebsite(websiteId, { + userId, + teamId: null, + }); + + return json(website); + } else if (teamId) { + if (!(await canTransferWebsiteToTeam(auth, websiteId, teamId))) { + return unauthorized(); + } + + const website = await updateWebsite(websiteId, { + userId: null, + teamId, + }); + + return json(website); + } + + return badRequest(); +} diff --git a/src/app/api/websites/[websiteId]/values/route.ts b/src/app/api/websites/[websiteId]/values/route.ts new file mode 100644 index 0000000..172325e --- /dev/null +++ b/src/app/api/websites/[websiteId]/values/route.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { EVENT_COLUMNS, FILTER_COLUMNS, SEGMENT_TYPES, SESSION_COLUMNS } from '@/lib/constants'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { badRequest, json, unauthorized } from '@/lib/response'; +import { dateRangeParams, fieldsParam, searchParams } from '@/lib/schema'; +import { canViewWebsite } from '@/permissions'; +import { getWebsiteSegments } from '@/queries/prisma'; +import { getValues } from '@/queries/sql'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ websiteId: string }> }, +) { + const schema = z.object({ + type: fieldsParam, + ...dateRangeParams, + ...searchParams, + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { websiteId } = await params; + + if (!(await canViewWebsite(auth, websiteId))) { + return unauthorized(); + } + + const { type } = query; + + if (!SESSION_COLUMNS.includes(type) && !EVENT_COLUMNS.includes(type) && !SEGMENT_TYPES[type]) { + return badRequest(); + } + + let values: any[]; + + if (SEGMENT_TYPES[type]) { + values = (await getWebsiteSegments(websiteId, type))?.data?.map(segment => ({ + value: segment.name, + })); + } else { + const filters = await getQueryFilters(query, websiteId); + values = await getValues(websiteId, FILTER_COLUMNS[type], filters); + } + + return json(values.filter(n => n).sort()); +} diff --git a/src/app/api/websites/route.ts b/src/app/api/websites/route.ts new file mode 100644 index 0000000..e2b26c1 --- /dev/null +++ b/src/app/api/websites/route.ts @@ -0,0 +1,86 @@ +import { z } from 'zod'; +import { uuid } from '@/lib/crypto'; +import redis from '@/lib/redis'; +import { getQueryFilters, parseRequest } from '@/lib/request'; +import { json, unauthorized } from '@/lib/response'; +import { pagingParams, searchParams } from '@/lib/schema'; +import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions'; +import { createWebsite, getWebsiteCount } from '@/queries/prisma'; +import { getAllUserWebsitesIncludingTeamOwner, getUserWebsites } from '@/queries/prisma/website'; + +const CLOUD_WEBSITE_LIMIT = 3; + +export async function GET(request: Request) { + const schema = z.object({ + ...pagingParams, + ...searchParams, + includeTeams: z.string().optional(), + }); + + const { auth, query, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const userId = auth.user.id; + + const filters = await getQueryFilters(query); + + if (query.includeTeams) { + return json(await getAllUserWebsitesIncludingTeamOwner(userId, filters)); + } + + return json(await getUserWebsites(userId, filters)); +} + +export async function POST(request: Request) { + const schema = z.object({ + name: z.string().max(100), + domain: z.string().max(500), + shareId: z.string().max(50).nullable().optional(), + teamId: z.uuid().nullable().optional(), + id: z.uuid().nullable().optional(), + }); + + const { auth, body, error } = await parseRequest(request, schema); + + if (error) { + return error(); + } + + const { id, name, domain, shareId, teamId } = body; + + if (process.env.CLOUD_MODE && !teamId) { + const account = await redis.client.get(`account:${auth.user.id}`); + + if (!account?.hasSubscription) { + const count = await getWebsiteCount(auth.user.id); + + if (count >= CLOUD_WEBSITE_LIMIT) { + return unauthorized({ message: 'Website limit reached.' }); + } + } + } + + if ((teamId && !(await canCreateTeamWebsite(auth, teamId))) || !(await canCreateWebsite(auth))) { + return unauthorized(); + } + + const data: any = { + id: id ?? uuid(), + createdBy: auth.user.id, + name, + domain, + shareId, + teamId, + }; + + if (!teamId) { + data.userId = auth.user.id; + } + + const website = await createWebsite(data); + + return json(website); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..afcbfc6 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from 'next'; +import { Suspense } from 'react'; +import { Providers } from './Providers'; +import '@fontsource/inter/300.css'; +import '@fontsource/inter/400.css'; +import '@fontsource/inter/500.css'; +import '@fontsource/inter/700.css'; +import '@umami/react-zen/styles.css'; +import '@/styles/global.css'; +import '@/styles/variables.css'; + +export default function ({ children }) { + if (process.env.DISABLE_UI) { + return ( + <html> + <body></body> + </html> + ); + } + + return ( + <html lang="en"> + <head> + <link rel="icon" href="/favicon.ico" /> + <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> + <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> + <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" /> + <link rel="manifest" href="/site.webmanifest" /> + <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" /> + <meta name="msapplication-TileColor" content="#da532c" /> + <meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" /> + <meta name="theme-color" content="#2f2f2f" media="(prefers-color-scheme: dark)" /> + <meta name="robots" content="noindex,nofollow" /> + </head> + <body> + <Suspense> + <Providers>{children}</Providers> + </Suspense> + </body> + </html> + ); +} + +export const metadata: Metadata = { + title: { + template: '%s | Umami', + default: 'Umami', + }, +}; diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx new file mode 100644 index 0000000..26d78dd --- /dev/null +++ b/src/app/login/LoginForm.tsx @@ -0,0 +1,70 @@ +import { + Column, + Form, + FormButtons, + FormField, + FormSubmitButton, + Heading, + Icon, + PasswordField, + TextField, +} from '@umami/react-zen'; +import { useRouter } from 'next/navigation'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { Logo } from '@/components/svg'; +import { setClientAuthToken } from '@/lib/client'; +import { setUser } from '@/store/app'; + +export function LoginForm() { + const { formatMessage, labels, getErrorMessage } = useMessages(); + const router = useRouter(); + const { mutateAsync, error } = useUpdateQuery('/auth/login'); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async ({ token, user }) => { + setClientAuthToken(token); + setUser(user); + router.push('/'); + }, + }); + }; + + return ( + <Column justifyContent="center" alignItems="center" gap="6"> + <Icon size="lg"> + <Logo /> + </Icon> + <Heading>umami</Heading> + <Form onSubmit={handleSubmit} error={getErrorMessage(error)}> + <FormField + label={formatMessage(labels.username)} + data-test="input-username" + name="username" + rules={{ required: formatMessage(labels.required) }} + > + <TextField autoComplete="username" /> + </FormField> + + <FormField + label={formatMessage(labels.password)} + data-test="input-password" + name="password" + rules={{ required: formatMessage(labels.required) }} + > + <PasswordField autoComplete="current-password" /> + </FormField> + <FormButtons> + <FormSubmitButton + data-test="button-submit" + variant="primary" + style={{ flex: 1 }} + isDisabled={false} + > + {formatMessage(labels.login)} + </FormSubmitButton> + </FormButtons> + </Form> + </Column> + ); +} diff --git a/src/app/login/LoginPage.tsx b/src/app/login/LoginPage.tsx new file mode 100644 index 0000000..6f485e3 --- /dev/null +++ b/src/app/login/LoginPage.tsx @@ -0,0 +1,11 @@ +'use client'; +import { Column } from '@umami/react-zen'; +import { LoginForm } from './LoginForm'; + +export function LoginPage() { + return ( + <Column alignItems="center" height="100vh" backgroundColor="2" paddingTop="12"> + <LoginForm /> + </Column> + ); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..ea27735 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next'; +import { LoginPage } from './LoginPage'; + +export default async function () { + if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) { + return null; + } + + return <LoginPage />; +} + +export const metadata: Metadata = { + title: 'Login', +}; diff --git a/src/app/logout/LogoutPage.tsx b/src/app/logout/LogoutPage.tsx new file mode 100644 index 0000000..33e1615 --- /dev/null +++ b/src/app/logout/LogoutPage.tsx @@ -0,0 +1,25 @@ +'use client'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +import { useApi } from '@/components/hooks'; +import { removeClientAuthToken } from '@/lib/client'; +import { setUser } from '@/store/app'; + +export function LogoutPage() { + const router = useRouter(); + const { post } = useApi(); + + useEffect(() => { + async function logout() { + await post('/auth/logout'); + + window.location.href = `${process.env.basePath || ''}/login`; + } + + removeClientAuthToken(); + setUser(null); + logout(); + }, [router, post]); + + return null; +} diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx new file mode 100644 index 0000000..2095278 --- /dev/null +++ b/src/app/logout/page.tsx @@ -0,0 +1,14 @@ +import type { Metadata } from 'next'; +import { LogoutPage } from './LogoutPage'; + +export default function () { + if (process.env.DISABLE_LOGIN || process.env.CLOUD_MODE) { + return null; + } + + return <LogoutPage />; +} + +export const metadata: Metadata = { + title: 'Logout', +}; diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx new file mode 100644 index 0000000..b376151 --- /dev/null +++ b/src/app/not-found.tsx @@ -0,0 +1,13 @@ +'use client'; +import { Flexbox } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; + +export default function () { + const { formatMessage, labels } = useMessages(); + + return ( + <Flexbox alignItems="center" justifyContent="center" flexGrow="1" minHeight="600px"> + <h1>{formatMessage(labels.pageNotFound)}</h1> + </Flexbox> + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..6f0033d --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,19 @@ +'use client'; +import { redirect } from 'next/navigation'; +import { useEffect } from 'react'; +import { LAST_TEAM_CONFIG } from '@/lib/constants'; +import { getItem } from '@/lib/storage'; + +export default function RootPage() { + useEffect(() => { + const lastTeam = getItem(LAST_TEAM_CONFIG); + + if (lastTeam) { + redirect(`/teams/${lastTeam}/websites`); + } else { + redirect(`/websites`); + } + }, []); + + return null; +} diff --git a/src/app/share/[...shareId]/Footer.tsx b/src/app/share/[...shareId]/Footer.tsx new file mode 100644 index 0000000..f294862 --- /dev/null +++ b/src/app/share/[...shareId]/Footer.tsx @@ -0,0 +1,12 @@ +import { Row, Text } from '@umami/react-zen'; +import { CURRENT_VERSION, HOMEPAGE_URL } from '@/lib/constants'; + +export function Footer() { + return ( + <Row as="footer" paddingY="6" justifyContent="flex-end"> + <a href={HOMEPAGE_URL} target="_blank"> + <Text weight="bold">umami</Text> {`v${CURRENT_VERSION}`} + </a> + </Row> + ); +} diff --git a/src/app/share/[...shareId]/Header.tsx b/src/app/share/[...shareId]/Header.tsx new file mode 100644 index 0000000..d7b7dcb --- /dev/null +++ b/src/app/share/[...shareId]/Header.tsx @@ -0,0 +1,24 @@ +import { Icon, Row, Text, ThemeButton } from '@umami/react-zen'; +import { LanguageButton } from '@/components/input/LanguageButton'; +import { PreferencesButton } from '@/components/input/PreferencesButton'; +import { Logo } from '@/components/svg'; + +export function Header() { + return ( + <Row as="header" justifyContent="space-between" alignItems="center" paddingY="3"> + <a href="https://umami.is" target="_blank" rel="noopener"> + <Row alignItems="center" gap> + <Icon> + <Logo /> + </Icon> + <Text weight="bold">umami</Text> + </Row> + </a> + <Row alignItems="center" gap> + <ThemeButton /> + <LanguageButton /> + <PreferencesButton /> + </Row> + </Row> + ); +} diff --git a/src/app/share/[...shareId]/SharePage.tsx b/src/app/share/[...shareId]/SharePage.tsx new file mode 100644 index 0000000..7ed0667 --- /dev/null +++ b/src/app/share/[...shareId]/SharePage.tsx @@ -0,0 +1,41 @@ +'use client'; +import { Column, useTheme } from '@umami/react-zen'; +import { useEffect } from 'react'; +import { WebsiteHeader } from '@/app/(main)/websites/[websiteId]/WebsiteHeader'; +import { WebsitePage } from '@/app/(main)/websites/[websiteId]/WebsitePage'; +import { WebsiteProvider } from '@/app/(main)/websites/WebsiteProvider'; +import { PageBody } from '@/components/common/PageBody'; +import { useShareTokenQuery } from '@/components/hooks'; +import { Footer } from './Footer'; +import { Header } from './Header'; + +export function SharePage({ shareId }) { + const { shareToken, isLoading } = useShareTokenQuery(shareId); + const { setTheme } = useTheme(); + + useEffect(() => { + const url = new URL(window?.location?.href); + const theme = url.searchParams.get('theme'); + + if (theme === 'light' || theme === 'dark') { + setTheme(theme); + } + }, []); + + if (isLoading || !shareToken) { + return null; + } + + return ( + <Column backgroundColor="2"> + <PageBody gap> + <Header /> + <WebsiteProvider websiteId={shareToken.websiteId}> + <WebsiteHeader showActions={false} /> + <WebsitePage websiteId={shareToken.websiteId} /> + </WebsiteProvider> + <Footer /> + </PageBody> + </Column> + ); +} diff --git a/src/app/share/[...shareId]/page.tsx b/src/app/share/[...shareId]/page.tsx new file mode 100644 index 0000000..b9900eb --- /dev/null +++ b/src/app/share/[...shareId]/page.tsx @@ -0,0 +1,7 @@ +import { SharePage } from './SharePage'; + +export default async function ({ params }: { params: Promise<{ shareId: string[] }> }) { + const { shareId } = await params; + + return <SharePage shareId={shareId[0]} />; +} diff --git a/src/app/sso/SSOPage.tsx b/src/app/sso/SSOPage.tsx new file mode 100644 index 0000000..3cc9509 --- /dev/null +++ b/src/app/sso/SSOPage.tsx @@ -0,0 +1,22 @@ +'use client'; +import { Loading } from '@umami/react-zen'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; +import { setClientAuthToken } from '@/lib/client'; + +export function SSOPage() { + const router = useRouter(); + const search = useSearchParams(); + const url = search.get('url'); + const token = search.get('token'); + + useEffect(() => { + if (url && token) { + setClientAuthToken(token); + + router.push(url); + } + }, [router, url, token]); + + return <Loading placement="absolute" />; +} diff --git a/src/app/sso/page.tsx b/src/app/sso/page.tsx new file mode 100644 index 0000000..f6290d4 --- /dev/null +++ b/src/app/sso/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react'; +import { SSOPage } from './SSOPage'; + +export default function () { + return ( + <Suspense> + <SSOPage /> + </Suspense> + ); +} diff --git a/src/assets/add-user.svg b/src/assets/add-user.svg new file mode 100644 index 0000000..c6b4f48 --- /dev/null +++ b/src/assets/add-user.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" data-name="Layer 2" viewBox="0 0 30 30"><path d="M15 14a5.5 5.5 0 1 1 5.5-5.5A5.51 5.51 0 0 1 15 14zm0-9a3.5 3.5 0 1 0 3.5 3.5A3.5 3.5 0 0 0 15 5zM7.5 24.5a1 1 0 0 1-1-1 8.5 8.5 0 0 1 13.6-6.8 1 1 0 1 1-1.2 1.6A6.44 6.44 0 0 0 15 17a6.51 6.51 0 0 0-6.5 6.5 1 1 0 0 1-1 1zM23 27a1 1 0 0 1-1-1v-6a1 1 0 0 1 2 0v6a1 1 0 0 1-1 1z"/><path d="M26 24h-6a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2z"/></svg>
\ No newline at end of file diff --git a/src/assets/bar-chart.svg b/src/assets/bar-chart.svg new file mode 100644 index 0000000..ae8b870 --- /dev/null +++ b/src/assets/bar-chart.svg @@ -0,0 +1 @@ +<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M7 13v9a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1zm7-12h-4a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zm8 5h-4a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1z"/></svg>
\ No newline at end of file diff --git a/src/assets/bars.svg b/src/assets/bars.svg new file mode 100644 index 0000000..ba383fa --- /dev/null +++ b/src/assets/bars.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M424 392H24c-13.2 0-24 10.8-24 24s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24Zm0-320H24C10.8 72 0 82.8 0 96s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24Zm0 160H24c-13.2 0-24 10.8-24 24s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24Z"/></svg>
\ No newline at end of file diff --git a/src/assets/bolt.svg b/src/assets/bolt.svg new file mode 100644 index 0000000..4654a1e --- /dev/null +++ b/src/assets/bolt.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"/></svg>
\ No newline at end of file diff --git a/src/assets/bookmark.svg b/src/assets/bookmark.svg new file mode 100644 index 0000000..5abc5ed --- /dev/null +++ b/src/assets/bookmark.svg @@ -0,0 +1 @@ +<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M3.515 22.875a1 1 0 0 0 1.015-.027L12 18.179l7.47 4.669A1 1 0 0 0 21 22V4a3 3 0 0 0-3-3H6a3 3 0 0 0-3 3v18a1 1 0 0 0 .515.875zM5 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v16.2l-6.47-4.044a1 1 0 0 0-1.06 0L5 20.2z"/></svg>
\ No newline at end of file diff --git a/src/assets/change.svg b/src/assets/change.svg new file mode 100644 index 0000000..bf907e6 --- /dev/null +++ b/src/assets/change.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512.013 512.013" style="enable-background:new 0 0 512.013 512.013" xml:space="preserve"><path d="m372.653 244.726 22.56 22.56 112-112c6.204-6.241 6.204-16.319 0-22.56l-112-112-22.56 22.72 84.8 84.64H.013v32h457.44l-84.8 84.64zm139.36 107.36H54.573l84.8-84.64-22.72-22.72-112 112c-6.204 6.241-6.204 16.319 0 22.56l112 112 22.56-22.56-84.64-84.64h457.44v-32z"/></svg>
\ No newline at end of file diff --git a/src/assets/compare.svg b/src/assets/compare.svg new file mode 100644 index 0000000..e037c24 --- /dev/null +++ b/src/assets/compare.svg @@ -0,0 +1 @@ +<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M6 22a1 1 0 0 1-.71-.29l-4-4a1 1 0 0 1 0-1.42l4-4a1 1 0 0 1 1.42 1.42L4.41 16H22a1 1 0 0 1 0 2H4.41l2.3 2.29a1 1 0 0 1 0 1.42A1 1 0 0 1 6 22zm12-10a1 1 0 0 1-.71-.29 1 1 0 0 1 0-1.42L19.59 8H2a1 1 0 0 1 0-2h17.59l-2.3-2.29a1 1 0 0 1 1.42-1.42l4 4a1 1 0 0 1 0 1.42l-4 4A1 1 0 0 1 18 12z"/></svg>
\ No newline at end of file diff --git a/src/assets/dashboard.svg b/src/assets/dashboard.svg new file mode 100644 index 0000000..398f2f2 --- /dev/null +++ b/src/assets/dashboard.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-layout-dashboard"><rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/></svg> diff --git a/src/assets/download.svg b/src/assets/download.svg new file mode 100644 index 0000000..b2482c9 --- /dev/null +++ b/src/assets/download.svg @@ -0,0 +1 @@ +<svg id="Layer_1" enable-background="new 0 0 100 100" viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><path d="m97.4999924 82.6562576.0000076-11.298912c0-1.957756-1.5870743-3.544838-3.544838-3.544838h-4.785324c-1.9577637 0-3.544838 1.5870743-3.544838 3.5448303l-.0000076 11.2989121c0 1.639595-1.329155 2.96875-2.96875 2.96875l-65.3124924-.0000229c-1.639596 0-2.96875-1.329155-2.968749-2.96875l.0000038-11.298912c0-1.957756-1.5870762-3.544838-3.544836-3.544838h-4.7853256c-1.9577594 0-3.5448372 1.5870743-3.544838 3.544838l-.0000036 11.298912c-.0000026 8.1979752 6.6457672 14.84375 14.8437443 14.84375l65.3124965.0000229c8.1979751 0 14.84375-6.6457672 14.84375-14.8437424z"/><path d="m29.6809349 44.1050034-3.3884087 3.3884048c-1.3843441 1.384346-1.384346 3.6288109-.0000019 5.0131569l19.5066929 19.5067101c2.3174515 2.3200302 6.0768623 2.3221207 8.3968925.0046768.0015564-.0015564.0031128-.0031204.0046692-.0046768l19.5067177-19.5066948c1.384346-1.3843422 1.384346-3.6288109 0-5.0131569l-3.3884125-3.3884048c-1.3843384-1.384346-3.6288071-1.384346-5.0131531-.0000038l-9.3684235 9.3684196.0000153-47.4285965c0-1.9577589-1.5870781-3.544837-3.5448341-3.5448377l-4.7853279-.0000014c-1.9577599-.0000007-3.544838 1.5870759-3.544838 3.5448353l-.0000153 47.4285965-9.3684158-9.3684235c-1.3843459-1.384346-3.6288127-1.384346-5.0131568-.0000038z"/></svg> diff --git a/src/assets/expand.svg b/src/assets/expand.svg new file mode 100644 index 0000000..43b9036 --- /dev/null +++ b/src/assets/expand.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 48 48"><path d="M7.5 40.018v-10.5c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5v11a4.5 4.5 0 0 0 4.5 4.5h12a2.5 2.5 0 0 0 0-5zm33 0H29a2.5 2.5 0 0 0 0 5h12a4.5 4.5 0 0 0 4.5-4.5v-11c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5zm-33-33H19a2.5 2.5 0 0 0 0-5H7a4.5 4.5 0 0 0-4.5 4.5v11a2.5 2.5 0 0 0 5 0zm33 0v10.5a2.5 2.5 0 0 0 5 0v-11a4.5 4.5 0 0 0-4.5-4.5H29a2.5 2.5 0 0 0 0 5z"/></svg>
\ No newline at end of file diff --git a/src/assets/export.svg b/src/assets/export.svg new file mode 100644 index 0000000..d7585b1 --- /dev/null +++ b/src/assets/export.svg @@ -0,0 +1 @@ +<svg id="Layer_1" enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><switch><g><path d="m8.7 7.7 2.3-2.3v9.6c0 .6.4 1 1 1s1-.4 1-1v-9.6l2.3 2.3c.4.4 1 .4 1.4 0 .4-.4.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0zm12.3 6.3c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1h-14c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1z"/></g></switch></svg> diff --git a/src/assets/flag.svg b/src/assets/flag.svg new file mode 100644 index 0000000..c375058 --- /dev/null +++ b/src/assets/flag.svg @@ -0,0 +1 @@ +<svg height="512" viewBox="0 0 510 510" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m393.159 121.41 69.152-86.44c-16.753-2.022-149.599-37.363-282.234-8.913V0h-30v361.898c-25.85 6.678-45 30.195-45 58.102v1.509c-34.191 6.969-60 37.272-60 73.491v15h240v-15c0-36.22-25.809-66.522-60-73.491V420c0-27.906-19.15-51.424-45-58.102V237.165c153.335-30.989 264.132 7.082 284.847 9.834zM252.506 480H77.647c6.19-17.461 22.873-30 42.43-30h90c19.556 0 36.238 12.539 42.429 30zm-57.429-60h-60c0-16.542 13.458-30 30-30s30 13.458 30 30zm-15-213.427V56.771c66.329-15.269 141.099-15.756 227.537-1.455l-50.619 63.274 48.8 85.4c-75.047-12.702-150.759-11.841-225.718 2.583z"/></svg>
\ No newline at end of file diff --git a/src/assets/funnel.svg b/src/assets/funnel.svg new file mode 100644 index 0000000..c97b2fd --- /dev/null +++ b/src/assets/funnel.svg @@ -0,0 +1 @@ +<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32"><path d="M29 11H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h26a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zM4 9h24V5H4z"/><path d="M25 17H7a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zM8 15h16v-4H8z"/><path d="M22 23H10a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zm-11-2h10v-4H11z"/><path d="M19 29h-6a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1zm-5-2h4v-4h-4z"/></svg> diff --git a/src/assets/gear.svg b/src/assets/gear.svg new file mode 100644 index 0000000..47805d4 --- /dev/null +++ b/src/assets/gear.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M504.265 315.978c0-8.652-4.607-16.844-12.359-21.392l-32.908-18.971a199.182 199.182 0 0 0 0-39.23l32.908-18.971c7.752-4.548 12.359-12.74 12.359-21.392 0-21.267-49.318-128.176-84.519-128.176-4.244 0-8.51 1.093-12.367 3.357l-32.78 18.969a195.058 195.058 0 0 0-34.068-19.744v-37.94c0-11.226-7.484-21.035-18.326-23.875C300.654 2.871 278.425 0 256.181 0a257.698 257.698 0 0 0-66.121 8.613c-10.842 2.84-18.326 12.649-18.326 23.875v37.94a195.058 195.058 0 0 0-34.068 19.744l-32.78-18.969a24.36 24.36 0 0 0-12.367-3.357h-.007C60.048 67.846 8 169.591 8 196.022c0 8.652 4.607 16.844 12.359 21.392l32.908 18.971a199.182 199.182 0 0 0 0 39.23l-32.908 18.971C12.607 299.134 8 307.326 8 315.978c0 21.267 49.318 128.176 84.519 128.176 4.244 0 8.51-1.093 12.367-3.357l32.78-18.969a195.058 195.058 0 0 0 34.068 19.744v37.94c0 11.226 7.484 21.035 18.326 23.875 21.551 5.742 43.78 8.613 66.024 8.613 22.246 0 44.506-2.871 66.121-8.613 10.842-2.84 18.326-12.649 18.326-23.875v-37.94a195.058 195.058 0 0 0 34.068-19.744l32.78 18.969a24.36 24.36 0 0 0 12.367 3.357c32.463 0 84.519-101.731 84.519-128.176Zm-88.904 73.981c-23.8-13.773-11.26-6.515-43.656-25.264-42.056 30.395-32.33 24.731-79.174 45.887v50.238a210.138 210.138 0 0 1-36.438 3.18 208.924 208.924 0 0 1-36.359-3.176v-50.242c-46.955-21.206-37.182-15.538-79.174-45.887l-43.636 25.254a207.379 207.379 0 0 1-36.407-63.109c21.126-12.177 11.844-6.826 43.571-25.117-2.539-25.64-3.811-35.644-3.811-45.683 0-10.022 1.268-20.08 3.811-45.763-31.89-18.385-22.517-12.982-43.584-25.125a207.107 207.107 0 0 1 36.4-63.111c23.8 13.773 11.26 6.515 43.656 25.264 42.056-30.395 32.33-24.731 79.174-45.887V51.18A210.146 210.146 0 0 1 256.172 48c15.425 0 27.954 1.694 36.359 3.176v50.242c46.955 21.206 37.182 15.538 79.174 45.887l43.638-25.254a207.414 207.414 0 0 1 36.405 63.109c-21.126 12.177-11.844 6.826-43.571 25.117 2.539 25.64 3.811 35.644 3.811 45.683 0 10.022-1.268 20.08-3.811 45.763 31.89 18.385 22.517 12.982 43.584 25.125a207.107 207.107 0 0 1-36.4 63.111ZM256.133 160c-52.875 0-96 43.125-96 96s43.125 96 96 96 96-43.125 96-96-43.125-96-96-96Zm0 144c-26.467 0-48-21.533-48-48s21.533-48 48-48 48 21.533 48 48-21.534 48-48 48Z"/></svg>
\ No newline at end of file diff --git a/src/assets/lightbulb.svg b/src/assets/lightbulb.svg new file mode 100644 index 0000000..46572b0 --- /dev/null +++ b/src/assets/lightbulb.svg @@ -0,0 +1 @@ +<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 512 512" viewBox="0 0 512 512"><path d="M223.718 124.76c-48.027 11.198-86.688 49.285-98.494 97.031-11.843 47.899 1.711 96.722 36.259 130.601C173.703 364.377 181 383.586 181 403.777V407c0 13.296 5.801 25.26 15 33.505V467c0 24.813 20.187 45 45 45h30c24.813 0 45-20.187 45-45v-26.495c9.199-8.245 15-20.208 15-33.505v-3.282c0-19.884 7.687-39.458 20.563-52.361C376.994 325.87 391 292.005 391 256c0-86.079-79.769-151.638-167.282-131.24zM286 467c0 8.271-6.729 15-15 15h-30c-8.271 0-15-6.729-15-15v-15h60v15zm44.326-136.834C311.689 348.843 301 375.651 301 403.718V407c0 8.271-6.729 15-15 15h-60c-8.271 0-15-6.729-15-15v-3.223c0-28.499-10.393-55.035-28.513-72.804-26.89-26.37-37.409-64.493-28.141-101.981 9.125-36.907 39.029-66.353 76.184-75.015C299.202 137.964 361 189.228 361 256c0 28.004-10.894 54.343-30.674 74.166zM139.327 118.114 96.9 75.688c-5.857-5.858-15.355-5.858-21.213 0-5.858 5.858-5.858 15.355 0 21.213l42.427 42.426c5.857 5.858 15.356 5.858 21.213 0 5.858-5.858 5.858-15.355 0-21.213zM76 241H15c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15zm421 0h-61c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15zM436.313 75.688c-5.856-5.858-15.354-5.858-21.213 0l-42.427 42.426c-5.858 5.857-5.858 15.355 0 21.213 5.857 5.858 15.355 5.858 21.213 0l42.427-42.426c5.858-5.857 5.858-15.355 0-21.213zM256 0c-8.284 0-15 6.716-15 15v61c0 8.284 6.716 15 15 15s15-6.716 15-15V15c0-8.284-6.716-15-15-15z"/><path d="M256 181c-6.166 0-12.447.739-18.658 2.194-25.865 6.037-47.518 27.328-53.879 52.979-1.994 8.041 2.907 16.175 10.947 18.17 8.042 1.994 16.176-2.909 18.17-10.948 3.661-14.758 16.647-27.5 31.593-30.989 3.982-.933 7.962-1.406 11.827-1.406 8.284 0 15-6.716 15-15s-6.716-15-15-15z"/></svg> diff --git a/src/assets/lightning.svg b/src/assets/lightning.svg new file mode 100644 index 0000000..14cb95d --- /dev/null +++ b/src/assets/lightning.svg @@ -0,0 +1 @@ +<svg xml:space="preserve" viewBox="0 0 682.667 682.667" xmlns="http://www.w3.org/2000/svg"><defs><clipPath clipPathUnits="userSpaceOnUse" id="a"><path d="M0 512h512V0H0Z"/></clipPath></defs><g clip-path="url(#a)" transform="matrix(1.33333 0 0 -1.33333 0 682.667)"><path d="M0 0h137.962L69.319-155.807h140.419L.242-482l55.349 222.794h-155.853z" style="fill:none;stroke:currentColor;stroke-width:30;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:1" transform="translate(201.262 496.994)"/></g></svg>
\ No newline at end of file diff --git a/src/assets/location.svg b/src/assets/location.svg new file mode 100644 index 0000000..f7f085e --- /dev/null +++ b/src/assets/location.svg @@ -0,0 +1 @@ +<svg height="512" viewBox="0 0 64 64" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M32 0A24.032 24.032 0 0 0 8 24c0 17.23 22.36 38.81 23.31 39.72a.99.99 0 0 0 1.38 0C33.64 62.81 56 41.23 56 24A24.032 24.032 0 0 0 32 0zm0 35a11 11 0 1 1 11-11 11.007 11.007 0 0 1-11 11z"/></svg>
\ No newline at end of file diff --git a/src/assets/lock.svg b/src/assets/lock.svg new file mode 100644 index 0000000..27fcc5e --- /dev/null +++ b/src/assets/lock.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 24 24"><path d="M18.75 9H18V6c0-3.309-2.691-6-6-6S6 2.691 6 6v3h-.75A2.253 2.253 0 0 0 3 11.25v10.5C3 22.991 4.01 24 5.25 24h13.5c1.24 0 2.25-1.009 2.25-2.25v-10.5C21 10.009 19.99 9 18.75 9zM8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v3H8zm5 10.722V19a1 1 0 1 1-2 0v-2.278c-.595-.347-1-.985-1-1.722 0-1.103.897-2 2-2s2 .897 2 2c0 .737-.405 1.375-1 1.722z"/></svg>
\ No newline at end of file diff --git a/src/assets/logo-white.svg b/src/assets/logo-white.svg new file mode 100644 index 0000000..20c41fb --- /dev/null +++ b/src/assets/logo-white.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 428 389.11"><circle cx="214.15" cy="181" r="171" fill="none" stroke="#fff" stroke-miterlimit="10" stroke-width="20"/><path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z" fill="#fff"/></svg>
\ No newline at end of file diff --git a/src/assets/logo.svg b/src/assets/logo.svg new file mode 100644 index 0000000..c7f4517 --- /dev/null +++ b/src/assets/logo.svg @@ -0,0 +1 @@ +<svg fill="currentColor" stroke="currentColor" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 428 389.11"><circle cx="214.15" cy="181" r="171" fill="none" stroke="currentColor" stroke-miterlimit="10" stroke-width="20"/><path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z"/></svg> diff --git a/src/assets/magnet.svg b/src/assets/magnet.svg new file mode 100644 index 0000000..79e1627 --- /dev/null +++ b/src/assets/magnet.svg @@ -0,0 +1 @@ +<svg fill="currentColor" height="512" viewBox="0 0 508.467 508.467" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M426.815 239.006c-11.722-11.724-30.702-11.729-42.427-.001L267.67 355.723c-53.811 53.809-142.478 19.197-140.68-54.511.547-22.415 9.826-43.738 26.129-60.041l116.717-116.717c11.724-11.722 11.728-30.702 0-42.427l-46.668-46.669c-11.725-11.725-30.702-11.726-42.427 0L60.629 155.47C21.579 194.52.047 246.44 0 301.665c-.093 110.827 88.182 206.288 206.244 206.394 56.778 0 109.204-21.924 148.29-61.01l118.948-118.948c11.724-11.722 11.728-30.702 0-42.427zM201.954 56.572l46.669 46.669-58.455 58.456-46.669-46.669zm131.367 369.264c-69.043 69.043-182.868 70.02-251.708.933-68.763-69.009-68.66-181.196.229-250.086l40.443-40.443 46.669 46.669-37.049 37.049c-45.115 45.112-46.916 116.85-3.395 160.371 43.279 43.279 115.221 41.756 160.372-3.394l37.049-37.049 46.669 46.669zm60.494-60.493-46.669-46.669 58.456-58.456 46.669 46.669zM379.357 95.099c15.199 3.839 30.418 19.07 34.336 34.192 2.089 8.058 10.303 12.828 18.283 10.758 8.02-2.078 12.836-10.264 10.758-18.283-6.651-25.662-30.176-49.223-56.03-55.753-8.032-2.027-16.188 2.838-18.217 10.869-2.029 8.032 2.837 16.189 10.87 18.217zm128.627 7.025C495.968 55.749 452.769 12.62 406.239.868c-8.032-2.027-16.188 2.838-18.217 10.869-2.029 8.032 2.838 16.188 10.87 18.217 35.882 9.063 70.769 43.871 80.051 79.695 2.088 8.058 10.304 12.828 18.283 10.758 8.02-2.078 12.836-10.263 10.758-18.283z"/></svg> diff --git a/src/assets/money.svg b/src/assets/money.svg new file mode 100644 index 0000000..2f364d8 --- /dev/null +++ b/src/assets/money.svg @@ -0,0 +1 @@ +<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><path d="M347 302c8.271 0 15 6.639 15 14.8h30c0-19.468-12.541-36.067-30-42.231V242h-30v32.58c-17.459 6.192-30 22.865-30 42.42 0 24.813 20.187 45 45 45 8.271 0 15 6.729 15 15s-6.729 15-15 15-15-6.729-15-15h-30c0 19.555 12.541 36.228 30 42.42v32.38h30v-32.38c17.459-6.192 30-22.865 30-42.42 0-24.813-20.187-45-45-45-8.271 0-15-6.729-15-15s6.729-15 15-15z"/><path d="M347 182c-5.057 0-10.058.242-15 .689V90c0-26.011-18.548-49.61-52.226-66.449C249.4 8.364 209.35 0 167 0 124.564 0 84.193 8.347 53.323 23.502 18.938 40.385 0 64 0 90v272c0 26 18.938 49.616 53.323 66.498C84.193 443.653 124.564 452 167 452c17.009 0 33.647-1.358 49.615-4.004C246.826 486.909 294.035 512 347 512c90.981 0 165-74.019 165-165s-74.019-165-165-165zM66.545 50.432C92.992 37.447 129.606 30 167 30c79.558 0 135 31.621 135 60s-55.442 60-135 60c-37.394 0-74.008-7.447-100.455-20.432C43.32 118.166 30 103.744 30 90s13.32-28.166 36.545-39.568zM30 142.265c6.724 5.137 14.512 9.907 23.323 14.233C84.193 171.653 124.564 180 167 180c42.35 0 82.4-8.364 112.774-23.551 8.359-4.18 15.783-8.776 22.226-13.722v45.51c-29.896 8.485-56.359 25.209-76.778 47.548C206.946 239.908 187.386 242 167 242c-37.394 0-74.008-7.447-100.455-20.432C43.32 210.166 30 195.744 30 182v-39.735zm0 92c6.724 5.137 14.512 9.907 23.323 14.233C84.193 263.653 124.564 272 167 272c11.581 0 22.942-.621 34.021-1.839a163.743 163.743 0 0 0-18.293 61.395c-5.211.286-10.465.444-15.728.444-37.394 0-74.008-7.447-100.455-20.432C43.32 300.166 30 285.744 30 272v-37.735zM167 422c-37.394 0-74.008-7.447-100.455-20.432C43.32 390.166 30 375.744 30 362v-37.736c6.724 5.137 14.512 9.907 23.323 14.233C84.193 353.653 124.564 362 167 362c5.23 0 10.459-.132 15.654-.388a163.726 163.726 0 0 0 16.486 58.557A280.559 280.559 0 0 1 167 422zm180 60c-74.439 0-135-60.561-135-135s60.561-135 135-135 135 60.561 135 135-60.561 135-135 135z"/></svg> diff --git a/src/assets/network.svg b/src/assets/network.svg new file mode 100644 index 0000000..93c941c --- /dev/null +++ b/src/assets/network.svg @@ -0,0 +1 @@ +<svg fill="currentColor" height="512" viewBox="0 0 32 32" width="512" xmlns="http://www.w3.org/2000/svg"><g id="_x30_6_network"><path d="m28 19c-.809 0-1.54.325-2.08.847l-6.011-3.01c.058-.271.091-.55.091-.837s-.033-.566-.091-.837l6.011-3.01c.54.522 1.271.847 2.08.847 1.654 0 3-1.346 3-3s-1.346-3-3-3-3 1.346-3 3c0 .123.022.24.036.359l-6.036 3.023c-.521-.597-1.21-1.035-2-1.24v-5.326c1.162-.415 2-1.514 2-2.816 0-1.654-1.346-3-3-3s-3 1.346-3 3c0 1.302.838 2.401 2 2.815v5.327c-.79.205-1.478.643-2 1.24l-6.037-3.022c.015-.12.037-.237.037-.36 0-1.654-1.346-3-3-3s-3 1.346-3 3 1.346 3 3 3c.809 0 1.54-.325 2.08-.847l6.011 3.01c-.058.271-.091.55-.091.837s.033.566.091.837l-6.011 3.01c-.54-.522-1.271-.847-2.08-.847-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3c0-.123-.022-.24-.036-.359l6.036-3.023c.521.597 1.21 1.035 2 1.24v5.326c-1.162.415-2 1.514-2 2.816 0 1.654 1.346 3 3 3s3-1.346 3-3c0-1.302-.838-2.401-2-2.816v-5.326c.79-.205 1.478-.643 2-1.24l6.037 3.022c-.015.12-.037.237-.037.36 0 1.654 1.346 3 3 3s3-1.346 3-3-1.346-3-3-3zm0-10c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1zm-24 2c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm0 12c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm12-20c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1zm0 26c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1zm0-11c-1.103 0-2-.897-2-2s.897-2 2-2 2 .897 2 2-.897 2-2 2zm12 5c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1z"/></g></svg> diff --git a/src/assets/nodes.svg b/src/assets/nodes.svg new file mode 100644 index 0000000..b3e22a7 --- /dev/null +++ b/src/assets/nodes.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 24 24"><path fill-rule="evenodd" d="M19 9.874A4.002 4.002 0 0 0 18 2a4.002 4.002 0 0 0-3.874 3H9.874A4.002 4.002 0 0 0 2 6a4.002 4.002 0 0 0 3 3.874v4.252A4.002 4.002 0 0 0 6 22a4.002 4.002 0 0 0 3.874-3h4.252A4.002 4.002 0 0 0 22 18a4.002 4.002 0 0 0-3-3.874zM6 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4zm3.874 3A4.007 4.007 0 0 1 7 9.874v4.252A4.007 4.007 0 0 1 9.874 17h4.252A4.007 4.007 0 0 1 17 14.126V9.874A4.007 4.007 0 0 1 14.126 7zM18 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4zm0 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM8 18a2 2 0 1 0-4 0 2 2 0 0 0 4 0z" clip-rule="evenodd"/></svg>
\ No newline at end of file diff --git a/src/assets/overview.svg b/src/assets/overview.svg new file mode 100644 index 0000000..ec44b4e --- /dev/null +++ b/src/assets/overview.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M452 36H60C26.916 36 0 62.916 0 96v240c0 33.084 26.916 60 60 60h176v40H132v40h248v-40H276v-40h176c33.084 0 60-26.916 60-60V96c0-33.084-26.916-60-60-60zm20 300c0 11.028-8.972 20-20 20H60c-11.028 0-20-8.972-20-20V96c0-11.028 8.972-20 20-20h392c11.028 0 20 8.972 20 20v240z"/></svg>
\ No newline at end of file diff --git a/src/assets/path.svg b/src/assets/path.svg new file mode 100644 index 0000000..e99207d --- /dev/null +++ b/src/assets/path.svg @@ -0,0 +1 @@ +<svg fill="currentColor" xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 64 64"><path d="m56.4 47.6-6-6c-.8-.8-2-.8-2.8 0s-.8 2 0 2.8l2.6 2.6H18.5c-3.6 0-6.5-2.9-6.5-6.5s2.9-6.5 6.5-6.5h27C51.3 34 56 29.3 56 23.5S51.3 13 45.5 13H22.7c-.9-3.4-4-6-7.7-6-4.4 0-8 3.6-8 8s3.6 8 8 8c3.7 0 6.8-2.6 7.7-6h22.8c3.6 0 6.5 2.9 6.5 6.5S49.1 30 45.5 30h-27C12.7 30 8 34.7 8 40.5S12.7 51 18.5 51h31.7l-2.6 2.6c-.8.8-.8 2 0 2.8.4.4.9.6 1.4.6s1-.2 1.4-.6l6-6c.8-.8.8-2 0-2.8M15 19c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4"/></svg> diff --git a/src/assets/profile.svg b/src/assets/profile.svg new file mode 100644 index 0000000..6a1af5a --- /dev/null +++ b/src/assets/profile.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M437.02 74.98C388.668 26.63 324.379 0 256 0S123.332 26.629 74.98 74.98C26.63 123.332 0 187.621 0 256s26.629 132.668 74.98 181.02C123.332 485.37 187.621 512 256 512s132.668-26.629 181.02-74.98C485.37 388.668 512 324.379 512 256s-26.629-132.668-74.98-181.02zM111.105 429.297c8.454-72.735 70.989-128.89 144.895-128.89 38.96 0 75.598 15.179 103.156 42.734 23.281 23.285 37.965 53.687 41.742 86.152C361.641 462.172 311.094 482 256 482s-105.637-19.824-144.895-52.703zM256 269.507c-42.871 0-77.754-34.882-77.754-77.753C178.246 148.879 213.13 114 256 114s77.754 34.879 77.754 77.754c0 42.871-34.883 77.754-77.754 77.754zm170.719 134.427a175.9 175.9 0 0 0-46.352-82.004c-18.437-18.438-40.25-32.27-64.039-40.938 28.598-19.394 47.426-52.16 47.426-89.238C363.754 132.34 315.414 84 256 84s-107.754 48.34-107.754 107.754c0 37.098 18.844 69.875 47.465 89.266-21.887 7.976-42.14 20.308-59.566 36.542-25.235 23.5-42.758 53.465-50.883 86.348C50.852 364.242 30 312.512 30 256 30 131.383 131.383 30 256 30s226 101.383 226 226c0 56.523-20.86 108.266-55.281 147.934zm0 0"/></svg>
\ No newline at end of file diff --git a/src/assets/pushpin.svg b/src/assets/pushpin.svg new file mode 100644 index 0000000..6926221 --- /dev/null +++ b/src/assets/pushpin.svg @@ -0,0 +1 @@ +<svg viewBox="0 0 1024 1024" fill="currentColor" height="1em" width="1em"><path d="M878.3 392.1 631.9 145.7c-6.5-6.5-15-9.7-23.5-9.7s-17 3.2-23.5 9.7L423.8 306.9c-12.2-1.4-24.5-2-36.8-2-73.2 0-146.4 24.1-206.5 72.3-15.4 12.3-16.6 35.4-2.7 49.4l181.7 181.7-215.4 215.2a15.8 15.8 0 0 0-4.6 9.8l-3.4 37.2c-.9 9.4 6.6 17.4 15.9 17.4.5 0 1 0 1.5-.1l37.2-3.4c3.7-.3 7.2-2 9.8-4.6l215.4-215.4 181.7 181.7c6.5 6.5 15 9.7 23.5 9.7 9.7 0 19.3-4.2 25.9-12.4 56.3-70.3 79.7-158.3 70.2-243.4l161.1-161.1c12.9-12.8 12.9-33.8 0-46.8z"/></svg>
\ No newline at end of file diff --git a/src/assets/redo.svg b/src/assets/redo.svg new file mode 100644 index 0000000..4544eb1 --- /dev/null +++ b/src/assets/redo.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M500 8h-27.711c-6.739 0-12.157 5.548-11.997 12.286l2.347 98.568C418.075 51.834 341.788 7.73 255.207 8.001 118.82 8.428 7.787 120.009 8 256.396 8.214 393.181 119.165 504 256 504c63.926 0 122.202-24.187 166.178-63.908 5.113-4.618 5.354-12.561.482-17.433l-19.738-19.738c-4.498-4.498-11.753-4.785-16.501-.552C351.787 433.246 306.105 452 256 452c-108.322 0-196-87.662-196-196 0-108.322 87.662-196 196-196 79.545 0 147.941 47.282 178.675 115.302l-126.389-3.009c-6.737-.16-12.286 5.257-12.286 11.997V212c0 6.627 5.373 12 12 12h192c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12z"/></svg>
\ No newline at end of file diff --git a/src/assets/reports.svg b/src/assets/reports.svg new file mode 100644 index 0000000..66dfc32 --- /dev/null +++ b/src/assets/reports.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M61.17 18.91A32 32 0 1 0 46.4 60.54l.15-.06.16-.1a31.93 31.93 0 0 0 14.47-41.44s-.01-.02-.01-.03zm-4.53-.16L34 28.91V4.1a28 28 0 0 1 22.64 14.65zM4 32A28 28 0 0 1 30 4.1V32a1.74 1.74 0 0 0 0 .39.17.17 0 0 0 0 .07 1.49 1.49 0 0 0 .15.4l12.76 24.9A28 28 0 0 1 4 32zm42.47 23.94L34.74 33l23.54-10.6a28 28 0 0 1-11.81 33.54z"/></svg>
\ No newline at end of file diff --git a/src/assets/security.svg b/src/assets/security.svg new file mode 100644 index 0000000..dd20891 --- /dev/null +++ b/src/assets/security.svg @@ -0,0 +1 @@ +<svg id="Layer_1" height="512" viewBox="0 0 36 36" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m18 34a1.07 1.07 0 0 1 -.48-.11l-4.87-2.43a13.79 13.79 0 0 1 -7.65-12.41v-12.14a1.07 1.07 0 0 1 1.05-1.07h3.47a7.45 7.45 0 0 0 4-1.19l3.87-2.48a1.07 1.07 0 0 1 1.15 0l3.87 2.48a7.45 7.45 0 0 0 4 1.19h3.47a1.07 1.07 0 0 1 1.12 1.07v12.14a13.79 13.79 0 0 1 -7.67 12.4l-4.87 2.43a1.07 1.07 0 0 1 -.46.12zm-10.88-26v11.05a11.67 11.67 0 0 0 6.49 10.49l4.39 2.2 4.39-2.2a11.67 11.67 0 0 0 6.49-10.49v-11.05h-2.4a9.57 9.57 0 0 1 -5.19-1.53l-3.29-2.14-3.29 2.12a9.57 9.57 0 0 1 -5.19 1.55z"/><path d="m18 18.8a4.8 4.8 0 1 1 4.8-4.8 4.81 4.81 0 0 1 -4.8 4.8zm0-7.47a2.67 2.67 0 1 0 2.67 2.67 2.67 2.67 0 0 0 -2.67-2.66z"/><path d="m24.4 24.67h-2.13a2.14 2.14 0 0 0 -2.13-2.13h-4.28a2.13 2.13 0 0 0 -2.13 2.13h-2.13a4.26 4.26 0 0 1 4.26-4.26h4.27a4.27 4.27 0 0 1 4.27 4.26z"/></svg>
\ No newline at end of file diff --git a/src/assets/speaker.svg b/src/assets/speaker.svg new file mode 100644 index 0000000..f243a49 --- /dev/null +++ b/src/assets/speaker.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M232.011 88.828c-5.664-5.664-13.217-8.784-21.269-8.784s-15.605 3.12-21.269 8.783c-9.917 9.917-11.446 25.09-4.593 36.632-23.293 86.372-34.167 96.094-78.604 135.776-15.831 14.138-35.533 31.731-61.302 57.5-5.434 5.434-8.426 12.673-8.426 20.383s2.993 14.949 8.426 20.383l70.981 70.98c5.434 5.435 12.672 8.427 20.382 8.427a28.7 28.7 0 0 0 14.046-3.637l72.768 72.768c2.574 2.574 6.09 3.962 9.896 3.961.789 0 1.59-.06 2.398-.181 3.883-.581 7.662-2.543 10.641-5.521l25.329-25.329c6.918-6.919 7.684-16.993 1.741-22.936l-39.164-39.164c11.586-20.762 9.203-46.431-6.187-64.762 29.684-32.251 46.532-43.128 122.192-63.532a30.076 30.076 0 0 0 15.361 4.203c7.703 0 15.405-2.933 21.269-8.796 11.728-11.729 11.728-30.811 0-42.539zM127.268 419.167l-70.981-70.981c-2.412-2.411-3.74-5.632-3.74-9.068s1.328-6.657 3.74-9.068c17.786-17.786 32.665-31.645 45.371-43.163l86.911 86.911c-11.519 12.706-25.378 27.585-43.164 45.371-2.412 2.411-5.632 3.74-9.068 3.74-3.437-.001-6.657-1.33-9.069-3.742zM260.1 469.653l-25.33 25.33a4.096 4.096 0 0 1-1.197.85L162.45 424.71a1243.745 1243.745 0 0 0 26.786-27.968l71.714 71.713a4.047 4.047 0 0 1-.85 1.198zm-38.055-62.731-21.982-21.981a2607.916 2607.916 0 0 0 14.157-15.763l2.712-3.035c8.895 11.831 10.752 27.329 5.113 40.779zm-19.759-48.401-3.004 3.362-85.711-85.711 3.361-3.003c44.419-39.665 57.85-51.661 80.687-133.656l138.322 138.322c-81.993 22.837-93.99 36.268-133.655 80.686zm173.027-83.854c-5.489 5.49-14.422 5.49-19.911 0L200.786 120.052c-5.489-5.489-5.489-14.421 0-19.91 2.642-2.643 6.178-4.098 9.956-4.098s7.313 1.455 9.955 4.098l154.616 154.615c5.489 5.489 5.489 14.421 0 19.91zm-22.558-151.968a8 8 0 0 1 0-11.314l43.904-43.904a8 8 0 0 1 11.313 11.314l-43.904 43.904c-1.562 1.562-3.609 2.343-5.657 2.343s-4.094-.781-5.656-2.343zm122.699 107.695a8 8 0 0 1-8 8h-62.09a8 8 0 0 1 0-16h62.09a8 8 0 0 1 8 8zM237.061 70.09V8a8 8 0 0 1 16 0v62.09a8 8 0 0 1-16 0z"/></svg>
\ No newline at end of file diff --git a/src/assets/switch.svg b/src/assets/switch.svg new file mode 100644 index 0000000..86166cc --- /dev/null +++ b/src/assets/switch.svg @@ -0,0 +1 @@ +<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round" stroke-linejoin="round" height="200px" width="200px" xmlns="http://www.w3.org/2000/svg"><path d="M16 3l4 4l-4 4"></path><path d="M10 7l10 0"></path><path d="M8 13l-4 4l4 4"></path><path d="M4 17l9 0"></path></svg> diff --git a/src/assets/tag.svg b/src/assets/tag.svg new file mode 100644 index 0000000..d123869 --- /dev/null +++ b/src/assets/tag.svg @@ -0,0 +1 @@ +<svg fill="currentColor" height="437pt" viewBox="0 0 437.004 437" width="437pt" xmlns="http://www.w3.org/2000/svg"><path d="M229 14.645A50.173 50.173 0 0 0 192.371.015L52.293 3.586C25.672 4.25 4.246 25.673 3.582 52.298L.016 192.37a50.215 50.215 0 0 0 14.625 36.633l193.367 193.36c19.539 19.495 51.168 19.495 70.707 0l143.644-143.645c19.528-19.524 19.528-51.184 0-70.711zm179.219 249.933-143.645 143.64c-11.722 11.7-30.703 11.7-42.426 0L28.785 214.86a30.131 30.131 0 0 1-8.777-21.98l3.566-140.074c.403-15.973 13.254-28.828 29.227-29.227l140.074-3.57c.254-.004.5-.008.754-.008a30.129 30.129 0 0 1 21.223 8.79l193.367 193.362c11.695 11.723 11.695 30.703 0 42.426zm0 0"/><path d="M130.719 82.574c-26.59 0-48.145 21.555-48.149 48.145 0 26.59 21.559 48.144 48.145 48.144 26.59 0 48.144-21.554 48.144-48.144-.03-26.574-21.566-48.114-48.14-48.145zm0 76.29c-15.547 0-28.145-12.602-28.149-28.145 0-15.543 12.602-28.145 28.145-28.145s28.144 12.602 28.144 28.145c-.015 15.535-12.605 28.125-28.14 28.144zm0 0"/></svg> diff --git a/src/assets/target.svg b/src/assets/target.svg new file mode 100644 index 0000000..ae9fef2 --- /dev/null +++ b/src/assets/target.svg @@ -0,0 +1 @@ +<svg fill="currentColor" clip-rule="evenodd" fill-rule="evenodd" height="512" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="M19.393 10.825a.75.75 0 0 1 1.458-.352c.181.75.277 1.533.277 2.338 0 5.485-4.453 9.939-9.939 9.939-5.485 0-9.939-4.454-9.939-9.939 0-5.486 4.454-9.939 9.939-9.939.805 0 1.588.096 2.338.277a.75.75 0 1 1-.352 1.458A8.442 8.442 0 0 0 2.75 12.811a8.442 8.442 0 0 0 8.439 8.439 8.442 8.442 0 0 0 8.204-10.425z"/><path d="M14.764 12.811a.75.75 0 0 1 1.5 0c0 2.8-2.274 5.074-5.075 5.074a5.077 5.077 0 0 1-5.074-5.074 5.077 5.077 0 0 1 5.074-5.075.75.75 0 0 1 0 1.5 3.575 3.575 0 1 0 3.575 3.575zm7.766-7.223-3.057 3.058a.75.75 0 0 1-.531.22h-3.058a.75.75 0 0 1-.75-.75V5.058a.75.75 0 0 1 .22-.531l3.058-3.057a.75.75 0 0 1 1.242.293L20.3 3.7l1.937.646a.75.75 0 0 1 .293 1.242zm-1.918-.202-1.142-.381a.753.753 0 0 1-.475-.475l-.381-1.142-1.98 1.98v1.998h1.998z"/><path d="M15.354 7.585a.75.75 0 1 1 1.061 1.061l-4.587 4.586a.749.749 0 1 1-1.06-1.06z"/></svg> diff --git a/src/assets/visitor.svg b/src/assets/visitor.svg new file mode 100644 index 0000000..829eb8e --- /dev/null +++ b/src/assets/visitor.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><path d="M256 0c-74.439 0-135 60.561-135 135s60.561 135 135 135 135-60.561 135-135S330.439 0 256 0zm167.966 358.195C387.006 320.667 338.009 300 286 300h-60c-52.008 0-101.006 20.667-137.966 58.195C51.255 395.539 31 444.833 31 497c0 8.284 6.716 15 15 15h420c8.284 0 15-6.716 15-15 0-52.167-20.255-101.461-57.034-138.805z"/></svg>
\ No newline at end of file diff --git a/src/assets/website.svg b/src/assets/website.svg new file mode 100644 index 0000000..6096a65 --- /dev/null +++ b/src/assets/website.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="enable-background:new 0 0 511.999 511.999" viewBox="0 0 511.999 511.999"><path d="M437.019 74.981C388.667 26.628 324.38 0 256 0 187.62 0 123.332 26.628 74.981 74.98 26.628 123.332 0 187.62 0 256s26.628 132.667 74.981 181.019c48.351 48.352 112.639 74.98 181.019 74.98 68.381 0 132.667-26.628 181.02-74.981C485.371 388.667 512 324.379 512 255.999s-26.629-132.667-74.981-181.018zM96.216 96.216c22.511-22.511 48.938-39.681 77.742-50.888-7.672 9.578-14.851 20.587-21.43 32.969-7.641 14.38-14.234 30.173-19.725 47.042-19.022-3.157-36.647-7.039-52.393-11.595a230.423 230.423 0 0 1 15.806-17.528zm-33.987 43.369c18.417 5.897 39.479 10.87 62.461 14.809-6.4 27.166-10.167 56.399-11.066 86.591H30.536c2.36-36.233 13.242-70.813 31.693-101.4zm-1.635 230.053c-17.455-29.899-27.769-63.481-30.059-98.623h83.146c.982 29.329 4.674 57.731 10.858 84.186-23.454 3.802-45.045 8.649-63.945 14.437zm35.622 46.146a229.917 229.917 0 0 1-17.831-20.055c16.323-4.526 34.571-8.359 54.214-11.433 5.53 17.103 12.194 33.105 19.928 47.662 7.17 13.493 15.053 25.349 23.51 35.505-29.61-11.183-56.769-28.629-79.821-51.679zm144.768 62.331c-22.808-6.389-44.384-27.217-61.936-60.249-6.139-11.552-11.531-24.155-16.15-37.587 24.73-2.722 51.045-4.331 78.086-4.709v102.545zm0-132.578c-29.988.409-59.217 2.292-86.59 5.507-6.038-24.961-9.671-51.978-10.668-80.028h97.259v74.521zm0-104.553h-97.315c.911-28.834 4.602-56.605 10.828-82.201 27.198 3.4 56.366 5.468 86.487 6.06v76.141zm0-106.176c-27.146-.547-53.403-2.317-77.958-5.205 4.591-13.292 9.941-25.768 16.022-37.215 17.551-33.032 39.128-53.86 61.936-60.249v102.669zm209.733 6.372c17.874 30.193 28.427 64.199 30.749 99.804h-83.088c-.889-29.844-4.584-58.749-10.85-85.647 23.133-3.736 44.456-8.489 63.189-14.157zm-34.934-44.964a230.122 230.122 0 0 1 16.914 18.91c-16.073 4.389-33.972 8.114-53.204 11.112-5.548-17.208-12.243-33.305-20.02-47.941-6.579-12.382-13.758-23.391-21.43-32.969 28.802 11.207 55.23 28.377 77.74 50.888zm-144.767 174.8h97.259c-1.004 28.268-4.686 55.49-10.81 80.612-27.194-3.381-56.349-5.43-86.449-6.006v-74.606zm0-30.032v-76.041c30.005-.394 59.257-2.261 86.656-5.464 6.125 25.403 9.756 52.932 10.659 81.505h-97.315zm-.002-208.845h.001c22.808 6.389 44.384 27.217 61.936 60.249 6.178 11.627 11.601 24.318 16.24 37.848-24.763 2.712-51.108 4.309-78.177 4.674V32.139zm.002 445.976V375.657c27.12.532 53.357 2.286 77.903 5.156-4.579 13.232-9.911 25.654-15.967 37.053-17.552 33.032-39.128 53.86-61.936 60.249zm144.767-62.331c-23.051 23.051-50.21 40.496-79.821 51.678 8.457-10.156 16.34-22.011 23.51-35.504 7.62-14.341 14.198-30.088 19.68-46.906 19.465 3.213 37.473 7.186 53.515 11.859a230.268 230.268 0 0 1-16.884 18.873zm34.823-44.775c-18.635-5.991-40-11.032-63.326-15.01 6.296-26.68 10.048-55.36 11.041-84.983h83.146c-2.328 35.678-12.918 69.753-30.861 99.993z"/></svg>
\ No newline at end of file diff --git a/src/components/boards/Board.tsx b/src/components/boards/Board.tsx new file mode 100644 index 0000000..70f0fa0 --- /dev/null +++ b/src/components/boards/Board.tsx @@ -0,0 +1,9 @@ +import { Column } from '@umami/react-zen'; + +export interface BoardProps { + children?: React.ReactNode; +} + +export function Board({ children }: BoardProps) { + return <Column>{children}</Column>; +} diff --git a/src/components/charts/BarChart.tsx b/src/components/charts/BarChart.tsx new file mode 100644 index 0000000..7bfc72d --- /dev/null +++ b/src/components/charts/BarChart.tsx @@ -0,0 +1,131 @@ +import { useTheme } from '@umami/react-zen'; +import { useMemo, useState } from 'react'; +import { Chart, type ChartProps } from '@/components/charts/Chart'; +import { ChartTooltip } from '@/components/charts/ChartTooltip'; +import { useLocale } from '@/components/hooks'; +import { renderNumberLabels } from '@/lib/charts'; +import { getThemeColors } from '@/lib/colors'; +import { DATE_FORMATS, formatDate } from '@/lib/date'; +import { formatLongCurrency, formatLongNumber } from '@/lib/format'; + +const dateFormats = { + millisecond: 'T', + second: 'pp', + minute: 'p', + hour: 'p - PP', + day: 'PPPP', + week: 'PPPP', + month: 'LLLL yyyy', + quarter: 'qqq', + year: 'yyyy', +}; + +export interface BarChartProps extends ChartProps { + unit?: string; + stacked?: boolean; + currency?: string; + renderXLabel?: (label: string, index: number, values: any[]) => string; + renderYLabel?: (label: string, index: number, values: any[]) => string; + XAxisType?: string; + YAxisType?: string; + minDate?: Date; + maxDate?: Date; +} + +export function BarChart({ + chartData, + renderXLabel, + renderYLabel, + unit, + XAxisType = 'timeseries', + YAxisType = 'linear', + stacked = false, + minDate, + maxDate, + currency, + ...props +}: BarChartProps) { + const [tooltip, setTooltip] = useState(null); + const { theme } = useTheme(); + const { locale } = useLocale(); + const { colors } = useMemo(() => getThemeColors(theme), [theme]); + + const chartOptions: any = useMemo(() => { + return { + __id: Date.now(), + scales: { + x: { + type: XAxisType, + stacked: true, + min: formatDate(minDate, DATE_FORMATS[unit], locale), + max: formatDate(maxDate, DATE_FORMATS[unit], locale), + offset: true, + time: { + unit, + }, + grid: { + display: false, + }, + border: { + color: colors.chart.line, + }, + ticks: { + color: colors.chart.text, + autoSkip: false, + maxRotation: 0, + callback: renderXLabel, + }, + }, + y: { + type: YAxisType, + min: 0, + beginAtZero: true, + stacked: !!stacked, + grid: { + color: colors.chart.line, + }, + border: { + color: colors.chart.line, + }, + ticks: { + color: colors.chart.text, + callback: renderYLabel || renderNumberLabels, + }, + }, + }, + }; + }, [chartData, colors, unit, stacked, renderXLabel, renderYLabel]); + + const handleTooltip = ({ tooltip }: { tooltip: any }) => { + const { opacity, labelColors, dataPoints } = tooltip; + + setTooltip( + opacity + ? { + title: formatDate( + new Date(dataPoints[0].raw?.d || dataPoints[0].raw?.x || dataPoints[0].raw), + dateFormats[unit], + locale, + ), + color: labelColors?.[0]?.backgroundColor, + value: currency + ? formatLongCurrency(dataPoints[0].raw.y, currency) + : `${formatLongNumber(dataPoints[0].raw.y)} ${dataPoints[0].dataset.label}`, + } + : null, + ); + }; + + return ( + <> + <Chart + {...props} + type="bar" + chartData={chartData} + chartOptions={chartOptions} + onTooltip={handleTooltip} + /> + {tooltip && <ChartTooltip {...tooltip} />} + </> + ); +} diff --git a/src/components/charts/BubbleChart.tsx b/src/components/charts/BubbleChart.tsx new file mode 100644 index 0000000..bf487ac --- /dev/null +++ b/src/components/charts/BubbleChart.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Chart, type ChartProps } from '@/components/charts/Chart'; +import { ChartTooltip } from '@/components/charts/ChartTooltip'; + +export interface BubbleChartProps extends ChartProps { + type?: 'bubble'; +} + +export function BubbleChart({ type = 'bubble', ...props }: BubbleChartProps) { + const [tooltip, setTooltip] = useState(null); + + const handleTooltip = ({ tooltip }) => { + const { opacity, labelColors, title, dataPoints } = tooltip; + + setTooltip( + opacity + ? { + color: labelColors?.[0]?.backgroundColor, + value: `${title}: ${dataPoints[0].raw}`, + } + : null, + ); + }; + + return ( + <> + <Chart {...props} type={type} onTooltip={handleTooltip} /> + {tooltip && <ChartTooltip {...tooltip} />} + </> + ); +} diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx new file mode 100644 index 0000000..b6ae9d7 --- /dev/null +++ b/src/components/charts/Chart.tsx @@ -0,0 +1,130 @@ +import { Box, type BoxProps, Column } from '@umami/react-zen'; +import ChartJS, { + type ChartData, + type ChartOptions, + type LegendItem, + type UpdateMode, +} from 'chart.js/auto'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Legend } from '@/components/metrics/Legend'; +import { DEFAULT_ANIMATION_DURATION } from '@/lib/constants'; + +ChartJS.defaults.font.family = 'Inter'; + +export interface ChartProps extends BoxProps { + type?: 'bar' | 'bubble' | 'doughnut' | 'pie' | 'line' | 'polarArea' | 'radar' | 'scatter'; + chartData?: ChartData & { focusLabel?: string }; + chartOptions?: ChartOptions; + updateMode?: UpdateMode; + animationDuration?: number; + onTooltip?: (model: any) => void; +} + +export function Chart({ + type, + chartData, + animationDuration = DEFAULT_ANIMATION_DURATION, + updateMode, + onTooltip, + chartOptions, + ...props +}: ChartProps) { + const canvas = useRef(null); + const chart = useRef(null); + const [legendItems, setLegendItems] = useState([]); + + const options = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: animationDuration, + resize: { + duration: 0, + }, + active: { + duration: 0, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + intersect: true, + external: onTooltip, + }, + }, + ...chartOptions, + }; + }, [chartOptions]); + + const handleLegendClick = (item: LegendItem) => { + if (type === 'bar') { + const { datasetIndex } = item; + const meta = chart.current.getDatasetMeta(datasetIndex); + + meta.hidden = + meta.hidden === null ? !chart.current.data.datasets[datasetIndex]?.hidden : null; + } else { + const { index } = item; + const meta = chart.current.getDatasetMeta(0); + const hidden = !!meta?.data?.[index]?.hidden; + + meta.data[index].hidden = !hidden; + chart.current.legend.legendItems[index].hidden = !hidden; + } + + chart.current.update(updateMode); + + setLegendItems(chart.current.legend.legendItems); + }; + + // Create chart + useEffect(() => { + if (canvas.current) { + chart.current = new ChartJS(canvas.current, { + type, + data: chartData, + options, + }); + + setLegendItems(chart.current.legend.legendItems); + } + + return () => { + chart.current?.destroy(); + }; + }, []); + + // Update chart + useEffect(() => { + if (chart.current && chartData) { + // Replace labels and datasets *in-place* + chart.current.data.labels = chartData.labels; + chart.current.data.datasets = chartData.datasets; + + if (chartData.focusLabel !== null) { + chart.current.data.datasets.forEach((ds: { hidden: boolean; label: any }) => { + ds.hidden = chartData.focusLabel ? ds.label !== chartData.focusLabel : false; + }); + } + + chart.current.options = options; + + chart.current.update(updateMode); + + setLegendItems(chart.current.legend.legendItems); + } + }, [chartData, options, updateMode]); + + return ( + <Column gap="6"> + <Box {...props}> + <canvas ref={canvas} /> + </Box> + <Legend items={legendItems} onClick={handleLegendClick} /> + </Column> + ); +} diff --git a/src/components/charts/ChartTooltip.tsx b/src/components/charts/ChartTooltip.tsx new file mode 100644 index 0000000..95ba2a2 --- /dev/null +++ b/src/components/charts/ChartTooltip.tsx @@ -0,0 +1,23 @@ +import { Column, FloatingTooltip, Row, StatusLight } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export function ChartTooltip({ + title, + color, + value, +}: { + title?: string; + color?: string; + value?: ReactNode; +}) { + return ( + <FloatingTooltip> + <Column gap="3" fontSize="1"> + {title && <Row alignItems="center">{title}</Row>} + <Row alignItems="center"> + <StatusLight color={color}>{value}</StatusLight> + </Row> + </Column> + </FloatingTooltip> + ); +} diff --git a/src/components/charts/PieChart.tsx b/src/components/charts/PieChart.tsx new file mode 100644 index 0000000..2470fe7 --- /dev/null +++ b/src/components/charts/PieChart.tsx @@ -0,0 +1,31 @@ +import { useState } from 'react'; +import { Chart, type ChartProps } from '@/components/charts/Chart'; +import { ChartTooltip } from '@/components/charts/ChartTooltip'; + +export interface PieChartProps extends ChartProps { + type?: 'doughnut' | 'pie'; +} + +export function PieChart({ type = 'pie', ...props }: PieChartProps) { + const [tooltip, setTooltip] = useState(null); + + const handleTooltip = ({ tooltip }) => { + const { opacity, labelColors, title, dataPoints } = tooltip; + + setTooltip( + opacity + ? { + color: labelColors?.[0]?.backgroundColor, + value: `${title}: ${dataPoints[0].raw}`, + } + : null, + ); + }; + + return ( + <> + <Chart {...props} type={type} onTooltip={handleTooltip} /> + {tooltip && <ChartTooltip {...tooltip} />} + </> + ); +} diff --git a/src/components/common/ActionForm.tsx b/src/components/common/ActionForm.tsx new file mode 100644 index 0000000..c6f44e8 --- /dev/null +++ b/src/components/common/ActionForm.tsx @@ -0,0 +1,15 @@ +import { Column, Row, Text } from '@umami/react-zen'; + +export function ActionForm({ label, description, children }) { + return ( + <Row alignItems="center" justifyContent="space-between" gap> + <Column gap="2"> + <Text weight="bold">{label}</Text> + <Text color="muted">{description}</Text> + </Column> + <Row alignItems="center" gap> + {children} + </Row> + </Row> + ); +} diff --git a/src/components/common/AnimatedDiv.tsx b/src/components/common/AnimatedDiv.tsx new file mode 100644 index 0000000..f994897 --- /dev/null +++ b/src/components/common/AnimatedDiv.tsx @@ -0,0 +1,3 @@ +import { type AnimatedComponent, animated } from '@react-spring/web'; + +export const AnimatedDiv: AnimatedComponent<any> = animated.div; diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx new file mode 100644 index 0000000..9b198b3 --- /dev/null +++ b/src/components/common/Avatar.tsx @@ -0,0 +1,21 @@ +import { lorelei } from '@dicebear/collection'; +import { createAvatar } from '@dicebear/core'; +import { useMemo } from 'react'; +import { getColor, getPastel } from '@/lib/colors'; + +const lib = lorelei; + +export function Avatar({ seed, size = 128, ...props }: { seed: string; size?: number }) { + const backgroundColor = getPastel(getColor(seed), 4); + + const avatar = useMemo(() => { + return createAvatar(lib, { + ...props, + seed, + size, + backgroundColor: [backgroundColor], + }).toDataUri(); + }, []); + + return <img src={avatar} alt="Avatar" style={{ borderRadius: '100%', width: size }} />; +} diff --git a/src/components/common/ConfirmationForm.tsx b/src/components/common/ConfirmationForm.tsx new file mode 100644 index 0000000..b909ef5 --- /dev/null +++ b/src/components/common/ConfirmationForm.tsx @@ -0,0 +1,42 @@ +import { Box, Button, Form, FormButtons, FormSubmitButton } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { useMessages } from '@/components/hooks'; + +export interface ConfirmationFormProps { + message: ReactNode; + buttonLabel?: ReactNode; + buttonVariant?: 'primary' | 'quiet' | 'danger'; + isLoading?: boolean; + error?: string | Error; + onConfirm?: () => void; + onClose?: () => void; +} + +export function ConfirmationForm({ + message, + buttonLabel, + buttonVariant, + isLoading, + error, + onConfirm, + onClose, +}: ConfirmationFormProps) { + const { formatMessage, labels, getErrorMessage } = useMessages(); + + return ( + <Form onSubmit={onConfirm} error={getErrorMessage(error)}> + <Box marginY="4">{message}</Box> + <FormButtons> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <FormSubmitButton + data-test="button-confirm" + isLoading={isLoading} + variant={buttonVariant} + isDisabled={false} + > + {buttonLabel || formatMessage(labels.ok)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/components/common/DataGrid.tsx b/src/components/common/DataGrid.tsx new file mode 100644 index 0000000..7e07b8d --- /dev/null +++ b/src/components/common/DataGrid.tsx @@ -0,0 +1,107 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { Column, Row, SearchField } from '@umami/react-zen'; +import { + cloneElement, + isValidElement, + type ReactElement, + type ReactNode, + useCallback, + useState, +} from 'react'; +import { Empty } from '@/components/common/Empty'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { Pager } from '@/components/common/Pager'; +import { useMessages, useMobile, useNavigation } from '@/components/hooks'; +import type { PageResult } from '@/lib/types'; + +const DEFAULT_SEARCH_DELAY = 600; + +export interface DataGridProps { + query: UseQueryResult<PageResult<any>, any>; + searchDelay?: number; + allowSearch?: boolean; + allowPaging?: boolean; + autoFocus?: boolean; + renderActions?: () => ReactNode; + renderEmpty?: () => ReactNode; + children: ReactNode | ((data: any) => ReactNode); +} + +export function DataGrid({ + query, + searchDelay = 600, + allowSearch, + allowPaging = true, + autoFocus, + renderActions, + renderEmpty = () => <Empty />, + children, +}: DataGridProps) { + const { formatMessage, labels } = useMessages(); + const { data, error, isLoading, isFetching } = query; + const { router, updateParams, query: queryParams } = useNavigation(); + const [search, setSearch] = useState(queryParams?.search || data?.search || ''); + const showPager = allowPaging && data && data.count > data.pageSize; + const { isMobile } = useMobile(); + const displayMode = isMobile ? 'cards' : undefined; + + const handleSearch = (value: string) => { + if (value !== search) { + setSearch(value); + router.push(updateParams({ search: value, page: 1 })); + } + }; + + const handlePageChange = useCallback( + (page: number) => { + router.push(updateParams({ search, page })); + }, + [search], + ); + + const child = data ? (typeof children === 'function' ? children(data) : children) : null; + + return ( + <Column gap="4" minHeight="300px"> + {allowSearch && ( + <Row alignItems="center" justifyContent="space-between" wrap="wrap" gap> + <SearchField + value={search} + onSearch={handleSearch} + delay={searchDelay || DEFAULT_SEARCH_DELAY} + autoFocus={autoFocus} + placeholder={formatMessage(labels.search)} + /> + {renderActions?.()} + </Row> + )} + <LoadingPanel + data={data} + isLoading={isLoading} + isFetching={isFetching} + error={error} + renderEmpty={renderEmpty} + > + {data && ( + <> + <Column> + {isValidElement(child) + ? cloneElement(child as ReactElement<any>, { displayMode }) + : child} + </Column> + {showPager && ( + <Row marginTop="6"> + <Pager + page={data.page} + pageSize={data.pageSize} + count={data.count} + onPageChange={handlePageChange} + /> + </Row> + )} + </> + )} + </LoadingPanel> + </Column> + ); +} diff --git a/src/components/common/DateDisplay.tsx b/src/components/common/DateDisplay.tsx new file mode 100644 index 0000000..0bece8a --- /dev/null +++ b/src/components/common/DateDisplay.tsx @@ -0,0 +1,28 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import { differenceInDays, isSameDay } from 'date-fns'; +import { useLocale } from '@/components/hooks'; +import { Calendar } from '@/components/icons'; +import { formatDate } from '@/lib/date'; + +export function DateDisplay({ startDate, endDate }) { + const { locale } = useLocale(); + const isSingleDate = differenceInDays(endDate, startDate) === 0; + + return ( + <Row gap="3" alignItems="center" wrap="nowrap"> + <Icon> + <Calendar /> + </Icon> + <Text wrap="nowrap"> + {isSingleDate ? ( + formatDate(startDate, 'PP', locale) + ) : ( + <> + {formatDate(startDate, 'PP', locale)} + {!isSameDay(startDate, endDate) && ` — ${formatDate(endDate, 'PP', locale)}`} + </> + )} + </Text> + </Row> + ); +} diff --git a/src/components/common/DateDistance.tsx b/src/components/common/DateDistance.tsx new file mode 100644 index 0000000..e8bd278 --- /dev/null +++ b/src/components/common/DateDistance.tsx @@ -0,0 +1,19 @@ +import { Text } from '@umami/react-zen'; +import { formatDistanceToNow } from 'date-fns'; +import { useLocale, useTimezone } from '@/components/hooks'; +import { isInvalidDate } from '@/lib/date'; + +export function DateDistance({ date }: { date: Date }) { + const { formatTimezoneDate } = useTimezone(); + const { dateLocale } = useLocale(); + + if (isInvalidDate(date)) { + return null; + } + + return ( + <Text title={formatTimezoneDate(date?.toISOString(), 'PPPpp')}> + {formatDistanceToNow(date, { addSuffix: true, locale: dateLocale })} + </Text> + ); +} diff --git a/src/components/common/Empty.tsx b/src/components/common/Empty.tsx new file mode 100644 index 0000000..8bd8d82 --- /dev/null +++ b/src/components/common/Empty.tsx @@ -0,0 +1,24 @@ +import { Row } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; + +export interface EmptyProps { + message?: string; +} + +export function Empty({ message }: EmptyProps) { + const { formatMessage, messages } = useMessages(); + + return ( + <Row + color="muted" + alignItems="center" + justifyContent="center" + width="100%" + height="100%" + minHeight="70px" + flexGrow={1} + > + {message || formatMessage(messages.noDataAvailable)} + </Row> + ); +} diff --git a/src/components/common/EmptyPlaceholder.tsx b/src/components/common/EmptyPlaceholder.tsx new file mode 100644 index 0000000..64492e0 --- /dev/null +++ b/src/components/common/EmptyPlaceholder.tsx @@ -0,0 +1,28 @@ +import { Column, Icon, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export interface EmptyPlaceholderProps { + title?: string; + description?: string; + icon?: ReactNode; + children?: ReactNode; +} + +export function EmptyPlaceholder({ title, description, icon, children }: EmptyPlaceholderProps) { + return ( + <Column alignItems="center" justifyContent="center" gap="5" height="100%" width="100%"> + {icon && ( + <Icon color="10" size="xl"> + {icon} + </Icon> + )} + {title && ( + <Text weight="bold" size="4"> + {title} + </Text> + )} + {description && <Text color="muted">{description}</Text>} + {children} + </Column> + ); +} diff --git a/src/components/common/ErrorBoundary.tsx b/src/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..4c0c82e --- /dev/null +++ b/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,38 @@ +import { Button, Column } from '@umami/react-zen'; +import type { ErrorInfo, ReactNode } from 'react'; +import { ErrorBoundary as Boundary } from 'react-error-boundary'; +import { useMessages } from '@/components/hooks'; + +const logError = (error: Error, info: ErrorInfo) => { + // eslint-disable-next-line no-console + console.error(error, info.componentStack); +}; + +export function ErrorBoundary({ children }: { children: ReactNode }) { + const { formatMessage, messages } = useMessages(); + + const fallbackRender = ({ error, resetErrorBoundary }) => { + return ( + <Column + role="alert" + gap + width="100%" + height="100%" + position="absolute" + justifyContent="center" + alignItems="center" + > + <h1>{formatMessage(messages.error)}</h1> + <h3>{error.message}</h3> + <pre>{error.stack}</pre> + <Button onClick={resetErrorBoundary}>OK</Button> + </Column> + ); + }; + + return ( + <Boundary fallbackRender={fallbackRender} onError={logError}> + {children} + </Boundary> + ); +} diff --git a/src/components/common/ErrorMessage.tsx b/src/components/common/ErrorMessage.tsx new file mode 100644 index 0000000..3c30151 --- /dev/null +++ b/src/components/common/ErrorMessage.tsx @@ -0,0 +1,16 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { AlertTriangle } from '@/components/icons'; + +export function ErrorMessage() { + const { formatMessage, messages } = useMessages(); + + return ( + <Row alignItems="center" justifyContent="center" gap> + <Icon> + <AlertTriangle /> + </Icon> + <Text>{formatMessage(messages.error)}</Text> + </Row> + ); +} diff --git a/src/components/common/ExternalLink.tsx b/src/components/common/ExternalLink.tsx new file mode 100644 index 0000000..dec0d16 --- /dev/null +++ b/src/components/common/ExternalLink.tsx @@ -0,0 +1,23 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import Link, { type LinkProps } from 'next/link'; +import type { ReactNode } from 'react'; +import { ExternalLink as LinkIcon } from '@/components/icons'; + +export function ExternalLink({ + href, + children, + ...props +}: LinkProps & { href: string; children: ReactNode }) { + return ( + <Row alignItems="center" overflow="hidden" gap> + <Text title={href} truncate> + <Link {...props} href={href} target="_blank"> + {children} + </Link> + </Text> + <Icon size="sm" strokeColor="muted"> + <LinkIcon /> + </Icon> + </Row> + ); +} diff --git a/src/components/common/Favicon.tsx b/src/components/common/Favicon.tsx new file mode 100644 index 0000000..a6b5e52 --- /dev/null +++ b/src/components/common/Favicon.tsx @@ -0,0 +1,22 @@ +import { useConfig } from '@/components/hooks'; +import { FAVICON_URL, GROUPED_DOMAINS } from '@/lib/constants'; + +function getHostName(url: string) { + const match = url.match(/^(?:https?:\/\/)?(?:[^@\n]+@)?([^:/\n?=]+)/im); + return match && match.length > 1 ? match[1] : null; +} + +export function Favicon({ domain, ...props }) { + const config = useConfig(); + + if (config?.privateMode) { + return null; + } + + const url = config?.faviconUrl || FAVICON_URL; + const hostName = domain ? getHostName(domain) : null; + const domainName = GROUPED_DOMAINS[hostName]?.domain || hostName; + const src = hostName ? url.replace(/\{\{\s*domain\s*}}/, domainName) : null; + + return hostName ? <img src={src} width={16} height={16} alt="" {...props} /> : null; +} diff --git a/src/components/common/FilterLink.tsx b/src/components/common/FilterLink.tsx new file mode 100644 index 0000000..d719a37 --- /dev/null +++ b/src/components/common/FilterLink.tsx @@ -0,0 +1,49 @@ +import { Icon, Row, Text } from '@umami/react-zen'; +import Link from 'next/link'; +import { type HTMLAttributes, type ReactNode, useState } from 'react'; +import { useMessages, useNavigation } from '@/components/hooks'; +import { ExternalLink } from '@/components/icons'; + +export interface FilterLinkProps extends HTMLAttributes<HTMLDivElement> { + type: string; + value: string; + label?: string; + icon?: ReactNode; + externalUrl?: string; +} + +export function FilterLink({ type, value, label, externalUrl, icon }: FilterLinkProps) { + const [showLink, setShowLink] = useState(false); + const { formatMessage, labels } = useMessages(); + const { updateParams, query } = useNavigation(); + const active = query[type] !== undefined; + const selected = query[type] === value; + + return ( + <Row + alignItems="center" + gap + fontWeight={active && selected ? 'bold' : undefined} + color={active && !selected ? 'muted' : undefined} + onMouseOver={() => setShowLink(true)} + onMouseOut={() => setShowLink(false)} + > + {icon} + {!value && `(${label || formatMessage(labels.unknown)})`} + {value && ( + <Text title={label || value} truncate> + <Link href={updateParams({ [type]: `eq.${value}` })} replace> + {label || value} + </Link> + </Text> + )} + {externalUrl && showLink && ( + <a href={externalUrl} target="_blank" rel="noreferrer noopener"> + <Icon color="muted"> + <ExternalLink /> + </Icon> + </a> + )} + </Row> + ); +} diff --git a/src/components/common/FilterRecord.tsx b/src/components/common/FilterRecord.tsx new file mode 100644 index 0000000..0400264 --- /dev/null +++ b/src/components/common/FilterRecord.tsx @@ -0,0 +1,117 @@ +import { Button, Column, Grid, Icon, Label, ListItem, Select, TextField } from '@umami/react-zen'; +import { useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { useFilters, useFormat, useWebsiteValuesQuery } from '@/components/hooks'; +import { X } from '@/components/icons'; +import { isSearchOperator } from '@/lib/params'; + +export interface FilterRecordProps { + websiteId: string; + type: string; + startDate: Date; + endDate: Date; + name: string; + operator: string; + value: string; + onSelect?: (name: string, value: any) => void; + onRemove?: (name: string) => void; + onChange?: (name: string, value: string) => void; +} + +export function FilterRecord({ + websiteId, + type, + startDate, + endDate, + name, + operator, + value, + onSelect, + onRemove, + onChange, +}: FilterRecordProps) { + const { fields, operators } = useFilters(); + const [selected, setSelected] = useState(value); + const [search, setSearch] = useState(''); + const { formatValue } = useFormat(); + const { data, isLoading } = useWebsiteValuesQuery({ + websiteId, + type, + search, + startDate, + endDate, + }); + const isSearch = isSearchOperator(operator); + const items = data?.filter(({ value }) => value) || []; + + const handleSearch = (value: string) => { + setSearch(value); + }; + + const handleSelectOperator = (value: any) => { + onSelect?.(name, value); + }; + + const handleSelectValue = (value: string) => { + setSelected(value); + onChange?.(name, value); + }; + + const renderValue = () => { + return formatValue(selected, type); + }; + + return ( + <Column> + <Label>{fields.find(f => f.name === name)?.label}</Label> + <Grid columns="1fr auto" gap> + <Grid columns={{ xs: '1fr', md: '200px 1fr' }} gap> + <Select + items={operators.filter(({ type }) => type === 'string')} + value={operator} + onChange={handleSelectOperator} + > + {({ name, label }: any) => { + return ( + <ListItem key={name} id={name}> + {label} + </ListItem> + ); + }} + </Select> + {isSearch && ( + <TextField value={selected} defaultValue={selected} onChange={handleSelectValue} /> + )} + {!isSearch && ( + <Select + items={items} + value={selected} + onChange={handleSelectValue} + searchValue={search} + renderValue={renderValue} + onSearch={handleSearch} + isLoading={isLoading} + listProps={{ renderEmptyState: () => <Empty /> }} + allowSearch + > + {items?.map(({ value }) => { + return ( + <ListItem key={value} id={value}> + {formatValue(value, type)} + </ListItem> + ); + })} + </Select> + )} + </Grid> + <Column justifyContent="flex-start"> + <Button onPress={() => onRemove?.(name)}> + <Icon> + <X /> + </Icon> + </Button> + </Column> + </Grid> + </Column> + ); +} diff --git a/src/components/common/GridRow.tsx b/src/components/common/GridRow.tsx new file mode 100644 index 0000000..72f1db6 --- /dev/null +++ b/src/components/common/GridRow.tsx @@ -0,0 +1,32 @@ +import { Grid } from '@umami/react-zen'; + +const LAYOUTS = { + one: { columns: '1fr' }, + two: { + columns: { + xs: '1fr', + md: 'repeat(auto-fill, minmax(560px, 1fr))', + }, + }, + three: { + columns: { + xs: '1fr', + md: 'repeat(auto-fill, minmax(360px, 1fr))', + }, + }, + 'one-two': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } }, + 'two-one': { columns: { xs: '1fr', md: 'repeat(3, 1fr)' } }, +}; + +export function GridRow(props: { + layout?: 'one' | 'two' | 'three' | 'one-two' | 'two-one' | 'compare'; + className?: string; + children?: any; +}) { + const { layout = 'two', children, ...otherProps } = props; + return ( + <Grid gap="3" {...LAYOUTS[layout]} {...otherProps}> + {children} + </Grid> + ); +} diff --git a/src/components/common/LinkButton.tsx b/src/components/common/LinkButton.tsx new file mode 100644 index 0000000..35292ba --- /dev/null +++ b/src/components/common/LinkButton.tsx @@ -0,0 +1,41 @@ +import { Button, type ButtonProps } from '@umami/react-zen'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; +import { useLocale } from '@/components/hooks'; + +export interface LinkButtonProps extends ButtonProps { + href: string; + target?: string; + scroll?: boolean; + variant?: any; + prefetch?: boolean; + asAnchor?: boolean; + children?: ReactNode; +} + +export function LinkButton({ + href, + variant, + scroll = true, + target, + prefetch, + children, + asAnchor, + ...props +}: LinkButtonProps) { + const { dir } = useLocale(); + + return ( + <Button {...props} variant={variant} asChild> + {asAnchor ? ( + <a href={href} target={target}> + {children} + </a> + ) : ( + <Link href={href} dir={dir} scroll={scroll} target={target} prefetch={prefetch}> + {children} + </Link> + )} + </Button> + ); +} diff --git a/src/components/common/LoadingPanel.tsx b/src/components/common/LoadingPanel.tsx new file mode 100644 index 0000000..fb37e14 --- /dev/null +++ b/src/components/common/LoadingPanel.tsx @@ -0,0 +1,71 @@ +import { Column, type ColumnProps, Loading } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { ErrorMessage } from '@/components/common/ErrorMessage'; + +export interface LoadingPanelProps extends ColumnProps { + data?: any; + error?: unknown; + isEmpty?: boolean; + isLoading?: boolean; + isFetching?: boolean; + loadingIcon?: 'dots' | 'spinner'; + loadingPlacement?: 'center' | 'absolute' | 'inline'; + renderEmpty?: () => ReactNode; + children: ReactNode; +} + +export function LoadingPanel({ + data, + error, + isEmpty, + isLoading, + isFetching, + loadingIcon = 'dots', + loadingPlacement = 'absolute', + renderEmpty = () => <Empty />, + children, + ...props +}: LoadingPanelProps): ReactNode { + const empty = isEmpty ?? checkEmpty(data); + + // Show loading spinner only if no data exists + if (isLoading || isFetching) { + return ( + <Column position="relative" height="100%" width="100%" {...props}> + <Loading icon={loadingIcon} placement={loadingPlacement} /> + </Column> + ); + } + + // Show error + if (error) { + return <ErrorMessage />; + } + + // Show empty state (once loaded) + if (!error && !isLoading && !isFetching && empty) { + return renderEmpty(); + } + + // Show main content when data exists + if (!isLoading && !isFetching && !error && !empty) { + return children; + } + + return null; +} + +function checkEmpty(data: any) { + if (!data) return false; + + if (Array.isArray(data)) { + return data.length <= 0; + } + + if (typeof data === 'object') { + return Object.keys(data).length <= 0; + } + + return !!data; +} diff --git a/src/components/common/PageBody.tsx b/src/components/common/PageBody.tsx new file mode 100644 index 0000000..f07e589 --- /dev/null +++ b/src/components/common/PageBody.tsx @@ -0,0 +1,42 @@ +'use client'; +import { AlertBanner, Column, type ColumnProps, Loading } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { useMessages } from '@/components/hooks'; + +const DEFAULT_WIDTH = '1320px'; + +export function PageBody({ + maxWidth = DEFAULT_WIDTH, + error, + isLoading, + children, + ...props +}: { + maxWidth?: string; + error?: unknown; + isLoading?: boolean; + children?: ReactNode; +} & ColumnProps) { + const { formatMessage, messages } = useMessages(); + + if (error) { + return <AlertBanner title={formatMessage(messages.error)} variant="error" />; + } + + if (isLoading) { + return <Loading placement="absolute" />; + } + + return ( + <Column + {...props} + width="100%" + paddingBottom="6" + maxWidth={maxWidth} + paddingX={{ xs: '3', md: '6' }} + style={{ margin: '0 auto' }} + > + {children} + </Column> + ); +} diff --git a/src/components/common/PageHeader.tsx b/src/components/common/PageHeader.tsx new file mode 100644 index 0000000..9216788 --- /dev/null +++ b/src/components/common/PageHeader.tsx @@ -0,0 +1,58 @@ +import { Column, Grid, Heading, Icon, Row, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { LinkButton } from './LinkButton'; + +export function PageHeader({ + title, + description, + label, + icon, + showBorder = true, + titleHref, + children, +}: { + title: string; + description?: string; + label?: ReactNode; + icon?: ReactNode; + showBorder?: boolean; + titleHref?: string; + allowEdit?: boolean; + className?: string; + children?: ReactNode; +}) { + return ( + <Grid + columns={{ xs: '1fr', md: '1fr 1fr' }} + paddingY="6" + marginBottom="6" + border={showBorder ? 'bottom' : undefined} + > + <Column gap="2"> + {label} + <Row alignItems="center" gap="3"> + {icon && ( + <Icon size="md" color="muted"> + {icon} + </Icon> + )} + {title && titleHref ? ( + <LinkButton href={titleHref} variant="quiet"> + <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading> + </LinkButton> + ) : ( + title && <Heading size={{ xs: '2', md: '3', lg: '4' }}>{title}</Heading> + )} + </Row> + {description && ( + <Text color="muted" truncate style={{ maxWidth: 600 }} title={description}> + {description} + </Text> + )} + </Column> + <Row justifyContent="flex-end" alignItems="center"> + {children} + </Row> + </Grid> + ); +} diff --git a/src/components/common/Pager.tsx b/src/components/common/Pager.tsx new file mode 100644 index 0000000..c65e2f6 --- /dev/null +++ b/src/components/common/Pager.tsx @@ -0,0 +1,60 @@ +import { Button, Icon, Row, Text } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { ChevronRight } from '@/components/icons'; + +export interface PagerProps { + page: string | number; + pageSize: string | number; + count: string | number; + onPageChange: (nextPage: number) => void; + className?: string; +} + +export function Pager({ page, pageSize, count, onPageChange }: PagerProps) { + const { formatMessage, labels } = useMessages(); + const maxPage = pageSize && count ? Math.ceil(+count / +pageSize) : 0; + const lastPage = page === maxPage; + const firstPage = page === 1; + + if (count === 0 || !maxPage) { + return null; + } + + const handlePageChange = (value: number) => { + const nextPage = +page + +value; + + if (nextPage > 0 && nextPage <= maxPage) { + onPageChange(nextPage); + } + }; + + if (maxPage === 1) { + return null; + } + + return ( + <Row alignItems="center" justifyContent="space-between" gap="3" flexGrow={1}> + <Text>{formatMessage(labels.numberOfRecords, { x: count.toLocaleString() })}</Text> + <Row alignItems="center" justifyContent="flex-end" gap="3"> + <Text> + {formatMessage(labels.pageOf, { + current: page.toLocaleString(), + total: maxPage.toLocaleString(), + })} + </Text> + <Row gap="1"> + <Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}> + <Icon size="sm" rotate={180}> + <ChevronRight /> + </Icon> + </Button> + <Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}> + <Icon size="sm"> + <ChevronRight /> + </Icon> + </Button> + </Row> + </Row> + </Row> + ); +} diff --git a/src/components/common/Panel.tsx b/src/components/common/Panel.tsx new file mode 100644 index 0000000..bb66746 --- /dev/null +++ b/src/components/common/Panel.tsx @@ -0,0 +1,64 @@ +import { + Button, + Column, + type ColumnProps, + Heading, + Icon, + Row, + Tooltip, + TooltipTrigger, +} from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { Maximize, X } from '@/components/icons'; + +export interface PanelProps extends ColumnProps { + title?: string; + allowFullscreen?: boolean; +} + +const fullscreenStyles = { + position: 'fixed', + width: '100%', + height: '100%', + top: 0, + left: 0, + border: 'none', + zIndex: 9999, +} as any; + +export function Panel({ title, allowFullscreen, style, children, ...props }: PanelProps) { + const { formatMessage, labels } = useMessages(); + const [isFullscreen, setIsFullscreen] = useState(false); + + const handleFullscreen = () => { + setIsFullscreen(!isFullscreen); + }; + + return ( + <Column + paddingY="6" + paddingX={{ xs: '3', md: '6' }} + border + borderRadius="3" + backgroundColor + position="relative" + gap + {...props} + style={{ ...style, ...(isFullscreen ? fullscreenStyles : {}) }} + > + {title && <Heading>{title}</Heading>} + {allowFullscreen && ( + <Row justifyContent="flex-end" alignItems="center"> + <TooltipTrigger delay={0} isDisabled={isFullscreen}> + <Button size="sm" variant="quiet" onPress={handleFullscreen}> + <Icon>{isFullscreen ? <X /> : <Maximize />}</Icon> + </Button> + <Tooltip>{formatMessage(labels.maximize)}</Tooltip> + </TooltipTrigger> + </Row> + )} + {children} + </Column> + ); +} diff --git a/src/components/common/SectionHeader.tsx b/src/components/common/SectionHeader.tsx new file mode 100644 index 0000000..5b911ef --- /dev/null +++ b/src/components/common/SectionHeader.tsx @@ -0,0 +1,28 @@ +import { Heading, Icon, Row, type RowProps, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export function SectionHeader({ + title, + description, + icon, + children, + ...props +}: { + title?: string; + description?: string; + icon?: ReactNode; + allowEdit?: boolean; + className?: string; + children?: ReactNode; +} & RowProps) { + return ( + <Row {...props} justifyContent="space-between" alignItems="center" height="60px"> + <Row gap="3" alignItems="center"> + {icon && <Icon size="md">{icon}</Icon>} + {title && <Heading size="3">{title}</Heading>} + {description && <Text color="muted">{description}</Text>} + </Row> + <Row justifyContent="flex-end">{children}</Row> + </Row> + ); +} diff --git a/src/components/common/SideMenu.tsx b/src/components/common/SideMenu.tsx new file mode 100644 index 0000000..92ff798 --- /dev/null +++ b/src/components/common/SideMenu.tsx @@ -0,0 +1,80 @@ +import { + Column, + Heading, + IconLabel, + NavMenu, + NavMenuGroup, + NavMenuItem, + type NavMenuProps, + Row, +} from '@umami/react-zen'; +import Link from 'next/link'; + +interface SideMenuData { + id: string; + label: string; + icon?: any; + path: string; +} + +interface SideMenuItems { + label?: string; + items: SideMenuData[]; +} + +export interface SideMenuProps extends NavMenuProps { + items: SideMenuItems[]; + title?: string; + selectedKey?: string; + allowMinimize?: boolean; +} + +export function SideMenu({ + items = [], + title, + selectedKey, + allowMinimize, + ...props +}: SideMenuProps) { + const renderItems = (items: SideMenuData[]) => { + return items?.map(({ id, label, icon, path }) => { + const isSelected = selectedKey === id; + + return ( + <Link key={id} href={path}> + <NavMenuItem isSelected={isSelected}> + <IconLabel icon={icon}>{label}</IconLabel> + </NavMenuItem> + </Link> + ); + }); + }; + + return ( + <Column gap overflowY="auto" justifyContent="space-between" position="sticky" top="20px"> + {title && ( + <Row padding> + <Heading size="1">{title}</Heading> + </Row> + )} + <NavMenu gap="6" {...props}> + {items?.map(({ label, items }, index) => { + if (label) { + return ( + <NavMenuGroup + title={label} + key={`${label}${index}`} + gap="1" + allowMinimize={allowMinimize} + marginBottom="3" + > + {renderItems(items)} + </NavMenuGroup> + ); + } + return null; + })} + </NavMenu> + </Column> + ); +} diff --git a/src/components/common/TypeConfirmationForm.tsx b/src/components/common/TypeConfirmationForm.tsx new file mode 100644 index 0000000..1121fa7 --- /dev/null +++ b/src/components/common/TypeConfirmationForm.tsx @@ -0,0 +1,55 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + TextField, +} from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; + +export function TypeConfirmationForm({ + confirmationValue, + buttonLabel, + buttonVariant, + isLoading, + error, + onConfirm, + onClose, +}: { + confirmationValue: string; + buttonLabel?: string; + buttonVariant?: 'primary' | 'outline' | 'quiet' | 'danger' | 'zero'; + isLoading?: boolean; + error?: string | Error; + onConfirm?: () => void; + onClose?: () => void; +}) { + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + if (!confirmationValue) { + return null; + } + + return ( + <Form onSubmit={onConfirm} error={getErrorMessage(error)}> + <p> + {formatMessage(messages.actionConfirmation, { + confirmation: confirmationValue, + })} + </p> + <FormField + label={formatMessage(labels.confirm)} + name="confirm" + rules={{ validate: value => value === confirmationValue }} + > + <TextField autoComplete="off" /> + </FormField> + <FormButtons> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <FormSubmitButton isLoading={isLoading} variant={buttonVariant}> + {buttonLabel || formatMessage(labels.ok)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/components/common/TypeIcon.tsx b/src/components/common/TypeIcon.tsx new file mode 100644 index 0000000..8894b3a --- /dev/null +++ b/src/components/common/TypeIcon.tsx @@ -0,0 +1,29 @@ +import { Row } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export function TypeIcon({ + type, + value, + children, +}: { + type: 'browser' | 'country' | 'device' | 'os'; + value: string; + children?: ReactNode; +}) { + return ( + <Row gap="3" alignItems="center"> + <img + src={`${process.env.basePath || ''}/images/${type}/${ + value?.replaceAll(' ', '-').toLowerCase() || 'unknown' + }.png`} + onError={e => { + e.currentTarget.src = `${process.env.basePath || ''}/images/${type}/unknown.png`; + }} + alt={value} + width={type === 'country' ? undefined : 16} + height={type === 'country' ? undefined : 16} + /> + {children} + </Row> + ); +} diff --git a/src/components/hooks/context/useLink.ts b/src/components/hooks/context/useLink.ts new file mode 100644 index 0000000..8766bbb --- /dev/null +++ b/src/components/hooks/context/useLink.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { LinkContext } from '@/app/(main)/links/LinkProvider'; + +export function useLink() { + return useContext(LinkContext); +} diff --git a/src/components/hooks/context/usePixel.ts b/src/components/hooks/context/usePixel.ts new file mode 100644 index 0000000..69cad6f --- /dev/null +++ b/src/components/hooks/context/usePixel.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { PixelContext } from '@/app/(main)/pixels/PixelProvider'; + +export function usePixel() { + return useContext(PixelContext); +} diff --git a/src/components/hooks/context/useTeam.ts b/src/components/hooks/context/useTeam.ts new file mode 100644 index 0000000..95ff4be --- /dev/null +++ b/src/components/hooks/context/useTeam.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { TeamContext } from '@/app/(main)/teams/TeamProvider'; + +export function useTeam() { + return useContext(TeamContext); +} diff --git a/src/components/hooks/context/useUser.ts b/src/components/hooks/context/useUser.ts new file mode 100644 index 0000000..fa97ea9 --- /dev/null +++ b/src/components/hooks/context/useUser.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { UserContext } from '@/app/(main)/admin/users/[userId]/UserProvider'; + +export function useUser() { + return useContext(UserContext); +} diff --git a/src/components/hooks/context/useWebsite.ts b/src/components/hooks/context/useWebsite.ts new file mode 100644 index 0000000..3d4be27 --- /dev/null +++ b/src/components/hooks/context/useWebsite.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { WebsiteContext } from '@/app/(main)/websites/WebsiteProvider'; + +export function useWebsite() { + return useContext(WebsiteContext); +} diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts new file mode 100644 index 0000000..e8e5c13 --- /dev/null +++ b/src/components/hooks/index.ts @@ -0,0 +1,84 @@ +'use client'; + +// Context hooks +export * from './context/useLink'; +export * from './context/usePixel'; +export * from './context/useTeam'; +export * from './context/useUser'; +export * from './context/useWebsite'; + +// Query hooks +export * from './queries/useActiveUsersQuery'; +export * from './queries/useDateRangeQuery'; +export * from './queries/useDeleteQuery'; +export * from './queries/useEventDataEventsQuery'; +export * from './queries/useEventDataPropertiesQuery'; +export * from './queries/useEventDataQuery'; +export * from './queries/useEventDataValuesQuery'; +export * from './queries/useLinkQuery'; +export * from './queries/useLinksQuery'; +export * from './queries/useLoginQuery'; +export * from './queries/usePixelQuery'; +export * from './queries/usePixelsQuery'; +export * from './queries/useRealtimeQuery'; +export * from './queries/useReportQuery'; +export * from './queries/useReportsQuery'; +export * from './queries/useResultQuery'; +export * from './queries/useSessionActivityQuery'; +export * from './queries/useSessionDataPropertiesQuery'; +export * from './queries/useSessionDataQuery'; +export * from './queries/useSessionDataValuesQuery'; +export * from './queries/useShareTokenQuery'; +export * from './queries/useTeamMembersQuery'; +export * from './queries/useTeamQuery'; +export * from './queries/useTeamsQuery'; +export * from './queries/useTeamWebsitesQuery'; +export * from './queries/useUpdateQuery'; +export * from './queries/useUserQuery'; +export * from './queries/useUsersQuery'; +export * from './queries/useUserTeamsQuery'; +export * from './queries/useUserWebsitesQuery'; +export * from './queries/useWebsiteCohortQuery'; +export * from './queries/useWebsiteCohortsQuery'; +export * from './queries/useWebsiteEventsQuery'; +export * from './queries/useWebsiteEventsSeriesQuery'; +export * from './queries/useWebsiteExpandedMetricsQuery'; +export * from './queries/useWebsiteMetricsQuery'; +export * from './queries/useWebsitePageviewsQuery'; +export * from './queries/useWebsiteQuery'; +export * from './queries/useWebsiteSegmentQuery'; +export * from './queries/useWebsiteSegmentsQuery'; +export * from './queries/useWebsiteSessionQuery'; +export * from './queries/useWebsiteSessionStatsQuery'; +export * from './queries/useWebsiteSessionsQuery'; +export * from './queries/useWebsiteStatsQuery'; +export * from './queries/useWebsitesQuery'; +export * from './queries/useWebsiteValuesQuery'; +export * from './queries/useWeeklyTrafficQuery'; + +// Regular hooks +export * from './useApi'; +export * from './useConfig'; +export * from './useCountryNames'; +export * from './useDateParameters'; +export * from './useDateRange'; +export * from './useDocumentClick'; +export * from './useEscapeKey'; +export * from './useFields'; +export * from './useFilterParameters'; +export * from './useFilters'; +export * from './useForceUpdate'; +export * from './useFormat'; +export * from './useGlobalState'; +export * from './useLanguageNames'; +export * from './useLocale'; +export * from './useMessages'; +export * from './useMobile'; +export * from './useModified'; +export * from './useNavigation'; +export * from './usePagedQuery'; +export * from './usePageParameters'; +export * from './useRegionNames'; +export * from './useSlug'; +export * from './useSticky'; +export * from './useTimezone'; diff --git a/src/components/hooks/queries/useActiveUsersQuery.ts b/src/components/hooks/queries/useActiveUsersQuery.ts new file mode 100644 index 0000000..42867c1 --- /dev/null +++ b/src/components/hooks/queries/useActiveUsersQuery.ts @@ -0,0 +1,12 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; + +export function useActyiveUsersQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + return useQuery<any>({ + queryKey: ['websites:active', websiteId], + queryFn: () => get(`/websites/${websiteId}/active`), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useDateRangeQuery.ts b/src/components/hooks/queries/useDateRangeQuery.ts new file mode 100644 index 0000000..84b7eec --- /dev/null +++ b/src/components/hooks/queries/useDateRangeQuery.ts @@ -0,0 +1,23 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; + +type DateRange = { + startDate?: string; + endDate?: string; +}; + +export function useDateRangeQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + + const { data } = useQuery<DateRange>({ + queryKey: ['date-range', websiteId], + queryFn: () => get(`/websites/${websiteId}/daterange`), + enabled: !!websiteId, + ...options, + }); + + return { + startDate: data?.startDate ? new Date(data.startDate) : null, + endDate: data?.endDate ? new Date(data.endDate) : null, + }; +} diff --git a/src/components/hooks/queries/useDeleteQuery.ts b/src/components/hooks/queries/useDeleteQuery.ts new file mode 100644 index 0000000..556231a --- /dev/null +++ b/src/components/hooks/queries/useDeleteQuery.ts @@ -0,0 +1,12 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useDeleteQuery(path: string, params?: Record<string, any>) { + const { del, useMutation } = useApi(); + const query = useMutation({ + mutationFn: () => del(path, params), + }); + const { touch } = useModified(); + + return { ...query, touch }; +} diff --git a/src/components/hooks/queries/useEventDataEventsQuery.ts b/src/components/hooks/queries/useEventDataEventsQuery.ts new file mode 100644 index 0000000..1401989 --- /dev/null +++ b/src/components/hooks/queries/useEventDataEventsQuery.ts @@ -0,0 +1,27 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useEventDataEventsQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:event-data:events', + { websiteId, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/event-data/events`, { + startAt, + endAt, + unit, + timezone, + ...filters, + }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useEventDataPropertiesQuery.ts b/src/components/hooks/queries/useEventDataPropertiesQuery.ts new file mode 100644 index 0000000..dfa6e92 --- /dev/null +++ b/src/components/hooks/queries/useEventDataPropertiesQuery.ts @@ -0,0 +1,27 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useEventDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery<any>({ + queryKey: [ + 'websites:event-data:properties', + { websiteId, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/event-data/properties`, { + startAt, + endAt, + unit, + timezone, + ...filters, + }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useEventDataQuery.ts b/src/components/hooks/queries/useEventDataQuery.ts new file mode 100644 index 0000000..2ccbd63 --- /dev/null +++ b/src/components/hooks/queries/useEventDataQuery.ts @@ -0,0 +1,27 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useEventDataQuery(websiteId: string, eventId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const params = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'websites:event-data', + { websiteId, eventId, startAt, endAt, unit, timezone, ...params }, + ], + queryFn: () => + get(`/websites/${websiteId}/event-data/${eventId}`, { + startAt, + endAt, + unit, + timezone, + ...params, + }), + enabled: !!(websiteId && eventId), + ...options, + }); +} diff --git a/src/components/hooks/queries/useEventDataValuesQuery.ts b/src/components/hooks/queries/useEventDataValuesQuery.ts new file mode 100644 index 0000000..6529e14 --- /dev/null +++ b/src/components/hooks/queries/useEventDataValuesQuery.ts @@ -0,0 +1,34 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useEventDataValuesQuery( + websiteId: string, + event: string, + propertyName: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery<any>({ + queryKey: [ + 'websites:event-data:values', + { websiteId, event, propertyName, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/event-data/values`, { + startAt, + endAt, + unit, + timezone, + ...filters, + event, + propertyName, + }), + enabled: !!(websiteId && propertyName), + ...options, + }); +} diff --git a/src/components/hooks/queries/useLinkQuery.ts b/src/components/hooks/queries/useLinkQuery.ts new file mode 100644 index 0000000..2a5d4a9 --- /dev/null +++ b/src/components/hooks/queries/useLinkQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useLinkQuery(linkId: string) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`link:${linkId}`); + + return useQuery({ + queryKey: ['link', { linkId, modified }], + queryFn: () => { + return get(`/links/${linkId}`); + }, + enabled: !!linkId, + }); +} diff --git a/src/components/hooks/queries/useLinksQuery.ts b/src/components/hooks/queries/useLinksQuery.ts new file mode 100644 index 0000000..ebf945f --- /dev/null +++ b/src/components/hooks/queries/useLinksQuery.ts @@ -0,0 +1,17 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useLinksQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) { + const { modified } = useModified('links'); + const { get } = useApi(); + + return usePagedQuery({ + queryKey: ['links', { teamId, modified }], + queryFn: pageParams => { + return get(teamId ? `/teams/${teamId}/links` : '/links', pageParams); + }, + ...options, + }); +} diff --git a/src/components/hooks/queries/useLoginQuery.ts b/src/components/hooks/queries/useLoginQuery.ts new file mode 100644 index 0000000..a64b784 --- /dev/null +++ b/src/components/hooks/queries/useLoginQuery.ts @@ -0,0 +1,23 @@ +import { setUser, useApp } from '@/store/app'; +import { useApi } from '../useApi'; + +const selector = (state: { user: any }) => state.user; + +export function useLoginQuery() { + const { post, useQuery } = useApi(); + const user = useApp(selector); + + const query = useQuery({ + queryKey: ['login'], + queryFn: async () => { + const data = await post('/auth/verify'); + + setUser(data); + + return data; + }, + enabled: !user, + }); + + return { user, setUser, ...query }; +} diff --git a/src/components/hooks/queries/usePixelQuery.ts b/src/components/hooks/queries/usePixelQuery.ts new file mode 100644 index 0000000..7fd83c2 --- /dev/null +++ b/src/components/hooks/queries/usePixelQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function usePixelQuery(pixelId: string) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`pixel:${pixelId}`); + + return useQuery({ + queryKey: ['pixel', { pixelId, modified }], + queryFn: () => { + return get(`/pixels/${pixelId}`); + }, + enabled: !!pixelId, + }); +} diff --git a/src/components/hooks/queries/usePixelsQuery.ts b/src/components/hooks/queries/usePixelsQuery.ts new file mode 100644 index 0000000..c431179 --- /dev/null +++ b/src/components/hooks/queries/usePixelsQuery.ts @@ -0,0 +1,17 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function usePixelsQuery({ teamId }: { teamId?: string }, options?: ReactQueryOptions) { + const { modified } = useModified('pixels'); + const { get } = useApi(); + + return usePagedQuery({ + queryKey: ['pixels', { teamId, modified }], + queryFn: pageParams => { + return get(teamId ? `/teams/${teamId}/pixels` : '/pixels', pageParams); + }, + ...options, + }); +} diff --git a/src/components/hooks/queries/useRealtimeQuery.ts b/src/components/hooks/queries/useRealtimeQuery.ts new file mode 100644 index 0000000..1a5bd1c --- /dev/null +++ b/src/components/hooks/queries/useRealtimeQuery.ts @@ -0,0 +1,17 @@ +import { REALTIME_INTERVAL } from '@/lib/constants'; +import type { RealtimeData } from '@/lib/types'; +import { useApi } from '../useApi'; + +export function useRealtimeQuery(websiteId: string) { + const { get, useQuery } = useApi(); + const { data, isLoading, error } = useQuery<RealtimeData>({ + queryKey: ['realtime', { websiteId }], + queryFn: async () => { + return get(`/realtime/${websiteId}`); + }, + enabled: !!websiteId, + refetchInterval: REALTIME_INTERVAL, + }); + + return { data, isLoading, error }; +} diff --git a/src/components/hooks/queries/useReportQuery.ts b/src/components/hooks/queries/useReportQuery.ts new file mode 100644 index 0000000..6973e2d --- /dev/null +++ b/src/components/hooks/queries/useReportQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useReportQuery(reportId: string) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`report:${reportId}`); + + return useQuery({ + queryKey: ['report', { reportId, modified }], + queryFn: () => { + return get(`/reports/${reportId}`); + }, + enabled: !!reportId, + }); +} diff --git a/src/components/hooks/queries/useReportsQuery.ts b/src/components/hooks/queries/useReportsQuery.ts new file mode 100644 index 0000000..ba1bdd4 --- /dev/null +++ b/src/components/hooks/queries/useReportsQuery.ts @@ -0,0 +1,19 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useReportsQuery( + { websiteId, type }: { websiteId: string; type?: string }, + options?: ReactQueryOptions, +) { + const { modified } = useModified(`reports:${type}`); + const { get } = useApi(); + + return usePagedQuery({ + queryKey: ['reports', { websiteId, type, modified }], + queryFn: async () => get('/reports', { websiteId, type }), + enabled: !!websiteId && !!type, + ...options, + }); +} diff --git a/src/components/hooks/queries/useResultQuery.ts b/src/components/hooks/queries/useResultQuery.ts new file mode 100644 index 0000000..c6fce12 --- /dev/null +++ b/src/components/hooks/queries/useResultQuery.ts @@ -0,0 +1,44 @@ +import { useDateParameters } from '@/components/hooks/useDateParameters'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useResultQuery<T = any>( + type: string, + params?: Record<string, any>, + options?: ReactQueryOptions<T>, +) { + const { websiteId, ...parameters } = params; + const { post, useQuery } = useApi(); + const { startDate, endDate, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery<T>({ + queryKey: [ + 'reports', + { + type, + websiteId, + startDate, + endDate, + timezone, + ...params, + ...filters, + }, + ], + queryFn: () => + post(`/reports/${type}`, { + websiteId, + type, + filters, + parameters: { + startDate, + endDate, + timezone, + ...parameters, + }, + }), + enabled: !!type, + ...options, + }); +} diff --git a/src/components/hooks/queries/useSessionActivityQuery.ts b/src/components/hooks/queries/useSessionActivityQuery.ts new file mode 100644 index 0000000..d8d34ac --- /dev/null +++ b/src/components/hooks/queries/useSessionActivityQuery.ts @@ -0,0 +1,21 @@ +import { useApi } from '../useApi'; + +export function useSessionActivityQuery( + websiteId: string, + sessionId: string, + startDate: Date, + endDate: Date, +) { + const { get, useQuery } = useApi(); + + return useQuery({ + queryKey: ['session:activity', { websiteId, sessionId, startDate, endDate }], + queryFn: () => { + return get(`/websites/${websiteId}/sessions/${sessionId}/activity`, { + startAt: +new Date(startDate), + endAt: +new Date(endDate), + }); + }, + enabled: Boolean(websiteId && sessionId && startDate && endDate), + }); +} diff --git a/src/components/hooks/queries/useSessionDataPropertiesQuery.ts b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts new file mode 100644 index 0000000..ac651bb --- /dev/null +++ b/src/components/hooks/queries/useSessionDataPropertiesQuery.ts @@ -0,0 +1,27 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useSessionDataPropertiesQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery<any>({ + queryKey: [ + 'websites:session-data:properties', + { websiteId, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/session-data/properties`, { + startAt, + endAt, + unit, + timezone, + ...filters, + }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useSessionDataQuery.ts b/src/components/hooks/queries/useSessionDataQuery.ts new file mode 100644 index 0000000..62b5398 --- /dev/null +++ b/src/components/hooks/queries/useSessionDataQuery.ts @@ -0,0 +1,12 @@ +import { useApi } from '../useApi'; + +export function useSessionDataQuery(websiteId: string, sessionId: string) { + const { get, useQuery } = useApi(); + + return useQuery({ + queryKey: ['session:data', { websiteId, sessionId }], + queryFn: () => { + return get(`/websites/${websiteId}/sessions/${sessionId}/properties`, { websiteId }); + }, + }); +} diff --git a/src/components/hooks/queries/useSessionDataValuesQuery.ts b/src/components/hooks/queries/useSessionDataValuesQuery.ts new file mode 100644 index 0000000..d5e180b --- /dev/null +++ b/src/components/hooks/queries/useSessionDataValuesQuery.ts @@ -0,0 +1,32 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useSessionDataValuesQuery( + websiteId: string, + propertyName: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery<any>({ + queryKey: [ + 'websites:session-data:values', + { websiteId, propertyName, startAt, endAt, unit, timezone, ...filters }, + ], + queryFn: () => + get(`/websites/${websiteId}/session-data/values`, { + startAt, + endAt, + unit, + timezone, + ...filters, + propertyName, + }), + enabled: !!(websiteId && propertyName), + ...options, + }); +} diff --git a/src/components/hooks/queries/useShareTokenQuery.ts b/src/components/hooks/queries/useShareTokenQuery.ts new file mode 100644 index 0000000..dbad3dc --- /dev/null +++ b/src/components/hooks/queries/useShareTokenQuery.ts @@ -0,0 +1,25 @@ +import { setShareToken, useApp } from '@/store/app'; +import { useApi } from '../useApi'; + +const selector = (state: { shareToken: string }) => state.shareToken; + +export function useShareTokenQuery(shareId: string): { + shareToken: any; + isLoading?: boolean; + error?: Error; +} { + const shareToken = useApp(selector); + const { get, useQuery } = useApi(); + const { isLoading, error } = useQuery({ + queryKey: ['share', shareId], + queryFn: async () => { + const data = await get(`/share/${shareId}`); + + setShareToken(data); + + return data; + }, + }); + + return { shareToken, isLoading, error }; +} diff --git a/src/components/hooks/queries/useTeamMembersQuery.ts b/src/components/hooks/queries/useTeamMembersQuery.ts new file mode 100644 index 0000000..6f6f815 --- /dev/null +++ b/src/components/hooks/queries/useTeamMembersQuery.ts @@ -0,0 +1,16 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useTeamMembersQuery(teamId: string) { + const { get } = useApi(); + const { modified } = useModified(`teams:members`); + + return usePagedQuery({ + queryKey: ['teams:members', { teamId, modified }], + queryFn: (params: any) => { + return get(`/teams/${teamId}/users`, params); + }, + enabled: !!teamId, + }); +} diff --git a/src/components/hooks/queries/useTeamQuery.ts b/src/components/hooks/queries/useTeamQuery.ts new file mode 100644 index 0000000..c076a6a --- /dev/null +++ b/src/components/hooks/queries/useTeamQuery.ts @@ -0,0 +1,17 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useTeamQuery(teamId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`teams:${teamId}`); + + return useQuery({ + queryKey: ['teams', { teamId, modified }], + queryFn: () => get(`/teams/${teamId}`), + enabled: !!teamId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useTeamWebsitesQuery.ts b/src/components/hooks/queries/useTeamWebsitesQuery.ts new file mode 100644 index 0000000..ffe601b --- /dev/null +++ b/src/components/hooks/queries/useTeamWebsitesQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useTeamWebsitesQuery(teamId: string) { + const { get } = useApi(); + const { modified } = useModified(`websites`); + + return usePagedQuery({ + queryKey: ['teams:websites', { teamId, modified }], + queryFn: (params: any) => { + return get(`/teams/${teamId}/websites`, params); + }, + }); +} diff --git a/src/components/hooks/queries/useTeamsQuery.ts b/src/components/hooks/queries/useTeamsQuery.ts new file mode 100644 index 0000000..f1a09f4 --- /dev/null +++ b/src/components/hooks/queries/useTeamsQuery.ts @@ -0,0 +1,20 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useTeamsQuery(params?: Record<string, any>, options?: ReactQueryOptions) { + const { get } = useApi(); + const { modified } = useModified(`teams`); + + return usePagedQuery({ + queryKey: ['teams:admin', { modified, ...params }], + queryFn: pageParams => { + return get(`/admin/teams`, { + ...pageParams, + ...params, + }); + }, + ...options, + }); +} diff --git a/src/components/hooks/queries/useUpdateQuery.ts b/src/components/hooks/queries/useUpdateQuery.ts new file mode 100644 index 0000000..85a9442 --- /dev/null +++ b/src/components/hooks/queries/useUpdateQuery.ts @@ -0,0 +1,15 @@ +import { useToast } from '@umami/react-zen'; +import type { ApiError } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useUpdateQuery(path: string, params?: Record<string, any>) { + const { post, useMutation } = useApi(); + const query = useMutation<any, ApiError, Record<string, any>>({ + mutationFn: (data: Record<string, any>) => post(path, { ...data, ...params }), + }); + const { touch } = useModified(); + const { toast } = useToast(); + + return { ...query, touch, toast }; +} diff --git a/src/components/hooks/queries/useUserQuery.ts b/src/components/hooks/queries/useUserQuery.ts new file mode 100644 index 0000000..07e23f0 --- /dev/null +++ b/src/components/hooks/queries/useUserQuery.ts @@ -0,0 +1,17 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useUserQuery(userId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`user:${userId}`); + + return useQuery({ + queryKey: ['users', { userId, modified }], + queryFn: () => get(`/users/${userId}`), + enabled: !!userId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useUserTeamsQuery.ts b/src/components/hooks/queries/useUserTeamsQuery.ts new file mode 100644 index 0000000..82f6549 --- /dev/null +++ b/src/components/hooks/queries/useUserTeamsQuery.ts @@ -0,0 +1,15 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useUserTeamsQuery(userId: string) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`teams`); + + return useQuery({ + queryKey: ['teams', { userId, modified }], + queryFn: () => { + return get(`/users/${userId}/teams`); + }, + enabled: !!userId, + }); +} diff --git a/src/components/hooks/queries/useUserWebsitesQuery.ts b/src/components/hooks/queries/useUserWebsitesQuery.ts new file mode 100644 index 0000000..f98eaff --- /dev/null +++ b/src/components/hooks/queries/useUserWebsitesQuery.ts @@ -0,0 +1,31 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useUserWebsitesQuery( + { userId, teamId }: { userId?: string; teamId?: string }, + params?: Record<string, any>, + options?: ReactQueryOptions, +) { + const { get } = useApi(); + const { modified } = useModified(`websites`); + + return usePagedQuery({ + queryKey: ['websites', { userId, teamId, modified, ...params }], + queryFn: pageParams => { + return get( + teamId + ? `/teams/${teamId}/websites` + : userId + ? `/users/${userId}/websites` + : '/me/websites', + { + ...pageParams, + ...params, + }, + ); + }, + ...options, + }); +} diff --git a/src/components/hooks/queries/useUsersQuery.ts b/src/components/hooks/queries/useUsersQuery.ts new file mode 100644 index 0000000..d87900b --- /dev/null +++ b/src/components/hooks/queries/useUsersQuery.ts @@ -0,0 +1,17 @@ +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useUsersQuery() { + const { get } = useApi(); + const { modified } = useModified(`users`); + + return usePagedQuery({ + queryKey: ['users:admin', { modified }], + queryFn: (pageParams: any) => { + return get('/admin/users', { + ...pageParams, + }); + }, + }); +} diff --git a/src/components/hooks/queries/useWebsiteCohortQuery.ts b/src/components/hooks/queries/useWebsiteCohortQuery.ts new file mode 100644 index 0000000..975766e --- /dev/null +++ b/src/components/hooks/queries/useWebsiteCohortQuery.ts @@ -0,0 +1,21 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteCohortQuery( + websiteId: string, + cohortId: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`cohorts`); + + return useQuery({ + queryKey: ['website:cohorts', { websiteId, cohortId, modified }], + queryFn: () => get(`/websites/${websiteId}/segments/${cohortId}`), + enabled: !!(websiteId && cohortId), + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteCohortsQuery.ts b/src/components/hooks/queries/useWebsiteCohortsQuery.ts new file mode 100644 index 0000000..e0cbf4c --- /dev/null +++ b/src/components/hooks/queries/useWebsiteCohortsQuery.ts @@ -0,0 +1,25 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import { useFilterParameters } from '@/components/hooks/useFilterParameters'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteCohortsQuery( + websiteId: string, + params?: Record<string, string>, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`cohorts`); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: ['website:cohorts', { websiteId, modified, ...filters, ...params }], + queryFn: pageParams => { + return get(`/websites/${websiteId}/segments`, { ...pageParams, ...filters, ...params }); + }, + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteEventsQuery.ts b/src/components/hooks/queries/useWebsiteEventsQuery.ts new file mode 100644 index 0000000..fc4dad5 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteEventsQuery.ts @@ -0,0 +1,39 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import { usePagedQuery } from '../usePagedQuery'; + +const EVENT_TYPES = { + views: 1, + events: 2, +}; + +export function useWebsiteEventsQuery( + websiteId: string, + params?: Record<string, any>, + options?: ReactQueryOptions, +) { + const { get } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return usePagedQuery({ + queryKey: [ + 'websites:events', + { websiteId, startAt, endAt, unit, timezone, ...filters, ...params }, + ], + queryFn: pageParams => + get(`/websites/${websiteId}/events`, { + startAt, + endAt, + unit, + timezone, + ...filters, + ...pageParams, + eventType: EVENT_TYPES[params.view], + }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts b/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts new file mode 100644 index 0000000..6c1d112 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteEventsSeriesQuery.ts @@ -0,0 +1,18 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useWebsiteEventsSeriesQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: ['websites:events:series', { websiteId, startAt, endAt, unit, timezone, ...filters }], + queryFn: () => + get(`/websites/${websiteId}/events/series`, { startAt, endAt, unit, timezone, ...filters }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts new file mode 100644 index 0000000..b2e9019 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteExpandedMetricsQuery.ts @@ -0,0 +1,51 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export type WebsiteExpandedMetricsData = { + name: string; + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; +}[]; + +export function useWebsiteExpandedMetricsQuery( + websiteId: string, + params: { type: string; limit?: number; search?: string }, + options?: ReactQueryOptions<WebsiteExpandedMetricsData>, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery<WebsiteExpandedMetricsData>({ + queryKey: [ + 'websites:metrics:expanded', + { + websiteId, + startAt, + endAt, + unit, + timezone, + ...filters, + ...params, + }, + ], + queryFn: async () => + get(`/websites/${websiteId}/metrics/expanded`, { + startAt, + endAt, + unit, + timezone, + ...filters, + ...params, + }), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteMetricsQuery.ts b/src/components/hooks/queries/useWebsiteMetricsQuery.ts new file mode 100644 index 0000000..67c5e4d --- /dev/null +++ b/src/components/hooks/queries/useWebsiteMetricsQuery.ts @@ -0,0 +1,47 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export type WebsiteMetricsData = { + x: string; + y: number; +}[]; + +export function useWebsiteMetricsQuery( + websiteId: string, + params: { type: string; limit?: number; search?: string }, + options?: ReactQueryOptions<WebsiteMetricsData>, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery<WebsiteMetricsData>({ + queryKey: [ + 'websites:metrics', + { + websiteId, + startAt, + endAt, + unit, + timezone, + ...filters, + ...params, + }, + ], + queryFn: async () => + get(`/websites/${websiteId}/metrics`, { + startAt, + endAt, + unit, + timezone, + ...filters, + ...params, + }), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsitePageviewsQuery.ts b/src/components/hooks/queries/useWebsitePageviewsQuery.ts new file mode 100644 index 0000000..b35c820 --- /dev/null +++ b/src/components/hooks/queries/useWebsitePageviewsQuery.ts @@ -0,0 +1,36 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export interface WebsitePageviewsData { + pageviews: { x: string; y: number }[]; + sessions: { x: string; y: number }[]; +} + +export function useWebsitePageviewsQuery( + { websiteId, compare }: { websiteId: string; compare?: string }, + options?: ReactQueryOptions<WebsitePageviewsData>, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const queryParams = useFilterParameters(); + + return useQuery<WebsitePageviewsData>({ + queryKey: [ + 'websites:pageviews', + { websiteId, compare, startAt, endAt, unit, timezone, ...queryParams }, + ], + queryFn: () => + get(`/websites/${websiteId}/pageviews`, { + compare, + startAt, + endAt, + unit, + timezone, + ...queryParams, + }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteQuery.ts b/src/components/hooks/queries/useWebsiteQuery.ts new file mode 100644 index 0000000..b9a5533 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteQuery.ts @@ -0,0 +1,17 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteQuery(websiteId: string, options?: ReactQueryOptions) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`website:${websiteId}`); + + return useQuery({ + queryKey: ['website', { websiteId, modified }], + queryFn: () => get(`/websites/${websiteId}`), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteSegmentQuery.ts b/src/components/hooks/queries/useWebsiteSegmentQuery.ts new file mode 100644 index 0000000..1923fbd --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSegmentQuery.ts @@ -0,0 +1,21 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteSegmentQuery( + websiteId: string, + segmentId: string, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`segments`); + + return useQuery({ + queryKey: ['website:segments', { websiteId, segmentId, modified }], + queryFn: () => get(`/websites/${websiteId}/segments/${segmentId}`), + enabled: !!(websiteId && segmentId), + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteSegmentsQuery.ts b/src/components/hooks/queries/useWebsiteSegmentsQuery.ts new file mode 100644 index 0000000..8d3af96 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSegmentsQuery.ts @@ -0,0 +1,24 @@ +import { keepPreviousData } from '@tanstack/react-query'; +import { useFilterParameters } from '@/components/hooks/useFilterParameters'; +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; + +export function useWebsiteSegmentsQuery( + websiteId: string, + params?: Record<string, string>, + options?: ReactQueryOptions, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`segments`); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: ['website:segments', { websiteId, modified, ...filters, ...params }], + queryFn: pageParams => + get(`/websites/${websiteId}/segments`, { ...pageParams, ...filters, ...params }), + enabled: !!websiteId, + placeholderData: keepPreviousData, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteSessionQuery.ts b/src/components/hooks/queries/useWebsiteSessionQuery.ts new file mode 100644 index 0000000..21e9491 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSessionQuery.ts @@ -0,0 +1,13 @@ +import { useApi } from '../useApi'; + +export function useWebsiteSessionQuery(websiteId: string, sessionId: string) { + const { get, useQuery } = useApi(); + + return useQuery({ + queryKey: ['session', { websiteId, sessionId }], + queryFn: () => { + return get(`/websites/${websiteId}/sessions/${sessionId}`); + }, + enabled: Boolean(websiteId && sessionId), + }); +} diff --git a/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts b/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts new file mode 100644 index 0000000..bac9fc9 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSessionStatsQuery.ts @@ -0,0 +1,17 @@ +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; + +export function useWebsiteSessionStatsQuery(websiteId: string, options?: Record<string, string>) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: ['sessions:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }], + queryFn: () => + get(`/websites/${websiteId}/sessions/stats`, { startAt, endAt, unit, timezone, ...filters }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteSessionsQuery.ts b/src/components/hooks/queries/useWebsiteSessionsQuery.ts new file mode 100644 index 0000000..31906be --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSessionsQuery.ts @@ -0,0 +1,34 @@ +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useFilterParameters } from '../useFilterParameters'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useWebsiteSessionsQuery( + websiteId: string, + params?: Record<string, string | number>, +) { + const { get } = useApi(); + const { modified } = useModified(`sessions`); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return usePagedQuery({ + queryKey: [ + 'sessions', + { websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters }, + ], + queryFn: pageParams => { + return get(`/websites/${websiteId}/sessions`, { + startAt, + endAt, + unit, + timezone, + ...filters, + ...pageParams, + ...params, + pageSize: 20, + }); + }, + }); +} diff --git a/src/components/hooks/queries/useWebsiteStatsQuery.ts b/src/components/hooks/queries/useWebsiteStatsQuery.ts new file mode 100644 index 0000000..e9a0c48 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteStatsQuery.ts @@ -0,0 +1,36 @@ +import type { UseQueryOptions } from '@tanstack/react-query'; +import { useDateParameters } from '@/components/hooks/useDateParameters'; +import { useApi } from '../useApi'; +import { useFilterParameters } from '../useFilterParameters'; + +export interface WebsiteStatsData { + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; + comparison: { + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; + }; +} + +export function useWebsiteStatsQuery( + websiteId: string, + options?: UseQueryOptions<WebsiteStatsData, Error, WebsiteStatsData>, +) { + const { get, useQuery } = useApi(); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery<WebsiteStatsData>({ + queryKey: ['websites:stats', { websiteId, startAt, endAt, unit, timezone, ...filters }], + queryFn: () => + get(`/websites/${websiteId}/stats`, { startAt, endAt, unit, timezone, ...filters }), + enabled: !!websiteId, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWebsiteValuesQuery.ts b/src/components/hooks/queries/useWebsiteValuesQuery.ts new file mode 100644 index 0000000..1e09736 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteValuesQuery.ts @@ -0,0 +1,62 @@ +import { useCountryNames } from '@/components/hooks/useCountryNames'; +import { useRegionNames } from '@/components/hooks/useRegionNames'; +import { useApi } from '../useApi'; +import { useLocale } from '../useLocale'; + +export function useWebsiteValuesQuery({ + websiteId, + type, + startDate, + endDate, + search, +}: { + websiteId: string; + type: string; + startDate: Date; + endDate: Date; + search?: string; +}) { + const { get, useQuery } = useApi(); + const { locale } = useLocale(); + const { countryNames } = useCountryNames(locale); + const { regionNames } = useRegionNames(locale); + + const names = { + country: countryNames, + region: regionNames, + }; + + const getSearch = (type: string, value: string) => { + if (value) { + const values = names[type]; + + if (values) { + return ( + Object.keys(values) + .reduce((arr: string[], key: string) => { + if (values[key].toLowerCase().includes(value.toLowerCase())) { + return arr.concat(key); + } + return arr; + }, []) + .slice(0, 5) + .join(',') || value + ); + } + + return value; + } + }; + + return useQuery({ + queryKey: ['websites:values', { websiteId, type, startDate, endDate, search }], + queryFn: () => + get(`/websites/${websiteId}/values`, { + type, + startAt: +startDate, + endAt: +endDate, + search: getSearch(type, search), + }), + enabled: !!(websiteId && type && startDate && endDate), + }); +} diff --git a/src/components/hooks/queries/useWebsitesQuery.ts b/src/components/hooks/queries/useWebsitesQuery.ts new file mode 100644 index 0000000..a7b6618 --- /dev/null +++ b/src/components/hooks/queries/useWebsitesQuery.ts @@ -0,0 +1,20 @@ +import type { ReactQueryOptions } from '@/lib/types'; +import { useApi } from '../useApi'; +import { useModified } from '../useModified'; +import { usePagedQuery } from '../usePagedQuery'; + +export function useWebsitesQuery(params?: Record<string, any>, options?: ReactQueryOptions) { + const { get } = useApi(); + const { modified } = useModified(`websites`); + + return usePagedQuery({ + queryKey: ['websites:admin', { modified, ...params }], + queryFn: pageParams => { + return get(`/admin/websites`, { + ...pageParams, + ...params, + }); + }, + ...options, + }); +} diff --git a/src/components/hooks/queries/useWeeklyTrafficQuery.ts b/src/components/hooks/queries/useWeeklyTrafficQuery.ts new file mode 100644 index 0000000..a76ebb3 --- /dev/null +++ b/src/components/hooks/queries/useWeeklyTrafficQuery.ts @@ -0,0 +1,28 @@ +import { useFilterParameters } from '@/components/hooks/useFilterParameters'; +import { useApi } from '../useApi'; +import { useDateParameters } from '../useDateParameters'; +import { useModified } from '../useModified'; + +export function useWeeklyTrafficQuery(websiteId: string, params?: Record<string, string | number>) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`sessions`); + const { startAt, endAt, unit, timezone } = useDateParameters(); + const filters = useFilterParameters(); + + return useQuery({ + queryKey: [ + 'sessions', + { websiteId, modified, startAt, endAt, unit, timezone, ...params, ...filters }, + ], + queryFn: () => { + return get(`/websites/${websiteId}/sessions/weekly`, { + startAt, + endAt, + unit, + timezone, + ...params, + ...filters, + }); + }, + }); +} diff --git a/src/components/hooks/useApi.ts b/src/components/hooks/useApi.ts new file mode 100644 index 0000000..35cabd5 --- /dev/null +++ b/src/components/hooks/useApi.ts @@ -0,0 +1,67 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { getClientAuthToken } from '@/lib/client'; +import { SHARE_TOKEN_HEADER } from '@/lib/constants'; +import { type FetchResponse, httpDelete, httpGet, httpPost, httpPut } from '@/lib/fetch'; +import { useApp } from '@/store/app'; + +const selector = (state: { shareToken: { token?: string } }) => state.shareToken; + +async function handleResponse(res: FetchResponse): Promise<any> { + if (!res.ok) { + const { message, code, status } = res?.data?.error || {}; + + return Promise.reject(Object.assign(new Error(message), { code, status })); + } + return Promise.resolve(res.data); +} + +export function useApi() { + const shareToken = useApp(selector); + + const defaultHeaders = { + authorization: `Bearer ${getClientAuthToken()}`, + [SHARE_TOKEN_HEADER]: shareToken?.token, + }; + const basePath = process.env.basePath; + + const getUrl = (url: string) => { + return url.startsWith('http') ? url : `${basePath || ''}/api${url}`; + }; + + const getHeaders = (headers: any = {}) => { + return { ...defaultHeaders, ...headers }; + }; + + return { + get: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpGet(getUrl(url), params, getHeaders(headers)).then(handleResponse); + }, + [httpGet], + ), + + post: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpPost(getUrl(url), params, getHeaders(headers)).then(handleResponse); + }, + [httpPost], + ), + + put: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpPut(getUrl(url), params, getHeaders(headers)).then(handleResponse); + }, + [httpPut], + ), + + del: useCallback( + async (url: string, params: object = {}, headers: object = {}) => { + return httpDelete(getUrl(url), params, getHeaders(headers)).then(handleResponse); + }, + [httpDelete], + ), + useQuery, + useMutation, + }; +} diff --git a/src/components/hooks/useConfig.ts b/src/components/hooks/useConfig.ts new file mode 100644 index 0000000..c1cdcaf --- /dev/null +++ b/src/components/hooks/useConfig.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useApi } from '@/components/hooks/useApi'; +import { setConfig, useApp } from '@/store/app'; + +export type Config = { + cloudMode: boolean; + faviconUrl?: string; + linksUrl?: string; + pixelsUrl?: string; + privateMode: boolean; + telemetryDisabled: boolean; + trackerScriptName?: string; + updatesDisabled: boolean; +}; + +export function useConfig(): Config { + const { config } = useApp(); + const { get } = useApi(); + + async function loadConfig() { + const data = await get(`/config`); + + setConfig(data); + } + + useEffect(() => { + if (!config) { + loadConfig(); + } + }, []); + + return config; +} diff --git a/src/components/hooks/useCountryNames.ts b/src/components/hooks/useCountryNames.ts new file mode 100644 index 0000000..1ec9fc1 --- /dev/null +++ b/src/components/hooks/useCountryNames.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { httpGet } from '@/lib/fetch'; +import enUS from '../../../public/intl/country/en-US.json'; + +const countryNames = { + 'en-US': enUS, +}; + +export function useCountryNames(locale: string) { + const [list, setList] = useState(countryNames[locale] || enUS); + + async function loadData(locale: string) { + const { data } = await httpGet(`${process.env.basePath || ''}/intl/country/${locale}.json`); + + if (data) { + countryNames[locale] = data; + setList(countryNames[locale]); + } else { + setList(enUS); + } + } + + useEffect(() => { + if (!countryNames[locale]) { + loadData(locale); + } else { + setList(countryNames[locale]); + } + }, [locale]); + + return { countryNames: list }; +} diff --git a/src/components/hooks/useDateParameters.ts b/src/components/hooks/useDateParameters.ts new file mode 100644 index 0000000..d84b423 --- /dev/null +++ b/src/components/hooks/useDateParameters.ts @@ -0,0 +1,18 @@ +import { useDateRange } from './useDateRange'; +import { useTimezone } from './useTimezone'; + +export function useDateParameters() { + const { + dateRange: { startDate, endDate, unit }, + } = useDateRange(); + const { timezone, localToUtc, canonicalizeTimezone } = useTimezone(); + + return { + startAt: +localToUtc(startDate), + endAt: +localToUtc(endDate), + startDate: localToUtc(startDate).toISOString(), + endDate: localToUtc(endDate).toISOString(), + unit, + timezone: canonicalizeTimezone(timezone), + }; +} diff --git a/src/components/hooks/useDateRange.ts b/src/components/hooks/useDateRange.ts new file mode 100644 index 0000000..755f36e --- /dev/null +++ b/src/components/hooks/useDateRange.ts @@ -0,0 +1,37 @@ +import { useMemo } from 'react'; +import { useLocale } from '@/components/hooks/useLocale'; +import { useNavigation } from '@/components/hooks/useNavigation'; +import { DATE_RANGE_CONFIG, DEFAULT_DATE_RANGE_VALUE } from '@/lib/constants'; +import { getCompareDate, getOffsetDateRange, parseDateRange } from '@/lib/date'; +import { getItem } from '@/lib/storage'; + +export function useDateRange(options: { ignoreOffset?: boolean; timezone?: string } = {}) { + const { + query: { date = '', offset = 0, compare = 'prev' }, + } = useNavigation(); + const { locale } = useLocale(); + + const dateRange = useMemo(() => { + const dateRangeObject = parseDateRange( + date || getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, + locale, + options.timezone, + ); + + return !options.ignoreOffset && offset + ? getOffsetDateRange(dateRangeObject, +offset) + : dateRangeObject; + }, [date, offset, options]); + + const dateCompare = getCompareDate(compare, dateRange.startDate, dateRange.endDate); + + return { + date, + offset, + compare, + isAllTime: date.endsWith(`:all`), + isCustomRange: date.startsWith('range:'), + dateRange, + dateCompare, + }; +} diff --git a/src/components/hooks/useDocumentClick.ts b/src/components/hooks/useDocumentClick.ts new file mode 100644 index 0000000..611f628 --- /dev/null +++ b/src/components/hooks/useDocumentClick.ts @@ -0,0 +1,13 @@ +import { useEffect } from 'react'; + +export function useDocumentClick(handler: (event: MouseEvent) => any) { + useEffect(() => { + document.addEventListener('click', handler); + + return () => { + document.removeEventListener('click', handler); + }; + }, [handler]); + + return null; +} diff --git a/src/components/hooks/useEscapeKey.ts b/src/components/hooks/useEscapeKey.ts new file mode 100644 index 0000000..cc1d308 --- /dev/null +++ b/src/components/hooks/useEscapeKey.ts @@ -0,0 +1,19 @@ +import { type KeyboardEvent, useCallback, useEffect } from 'react'; + +export function useEscapeKey(handler: (event: KeyboardEvent) => void) { + const escFunction = useCallback((event: KeyboardEvent) => { + if (event.key === 'Escape') { + handler(event); + } + }, []); + + useEffect(() => { + document.addEventListener('keydown', escFunction as any, false); + + return () => { + document.removeEventListener('keydown', escFunction as any, false); + }; + }, [escFunction]); + + return null; +} diff --git a/src/components/hooks/useFields.ts b/src/components/hooks/useFields.ts new file mode 100644 index 0000000..22a1dcf --- /dev/null +++ b/src/components/hooks/useFields.ts @@ -0,0 +1,23 @@ +import { useMessages } from './useMessages'; + +export function useFields() { + const { formatMessage, labels } = useMessages(); + + const fields = [ + { name: 'path', type: 'string', label: formatMessage(labels.path) }, + { name: 'query', type: 'string', label: formatMessage(labels.query) }, + { name: 'title', type: 'string', label: formatMessage(labels.pageTitle) }, + { name: 'referrer', type: 'string', label: formatMessage(labels.referrer) }, + { name: 'browser', type: 'string', label: formatMessage(labels.browser) }, + { name: 'os', type: 'string', label: formatMessage(labels.os) }, + { name: 'device', type: 'string', label: formatMessage(labels.device) }, + { name: 'country', type: 'string', label: formatMessage(labels.country) }, + { name: 'region', type: 'string', label: formatMessage(labels.region) }, + { name: 'city', type: 'string', label: formatMessage(labels.city) }, + { name: 'hostname', type: 'string', label: formatMessage(labels.hostname) }, + { name: 'tag', type: 'string', label: formatMessage(labels.tag) }, + { name: 'event', type: 'string', label: formatMessage(labels.event) }, + ]; + + return { fields }; +} diff --git a/src/components/hooks/useFilterParameters.ts b/src/components/hooks/useFilterParameters.ts new file mode 100644 index 0000000..5403212 --- /dev/null +++ b/src/components/hooks/useFilterParameters.ts @@ -0,0 +1,70 @@ +import { useMemo } from 'react'; +import { useNavigation } from './useNavigation'; + +export function useFilterParameters() { + const { + query: { + path, + referrer, + title, + query, + host, + os, + browser, + device, + country, + region, + city, + event, + tag, + hostname, + page, + pageSize, + search, + segment, + cohort, + }, + } = useNavigation(); + + return useMemo(() => { + return { + path, + referrer, + title, + query, + host, + os, + browser, + device, + country, + region, + city, + event, + tag, + hostname, + search, + segment, + cohort, + }; + }, [ + path, + referrer, + title, + query, + host, + os, + browser, + device, + country, + region, + city, + event, + tag, + hostname, + page, + pageSize, + search, + segment, + cohort, + ]); +} diff --git a/src/components/hooks/useFilters.ts b/src/components/hooks/useFilters.ts new file mode 100644 index 0000000..850e2af --- /dev/null +++ b/src/components/hooks/useFilters.ts @@ -0,0 +1,99 @@ +import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; +import { safeDecodeURIComponent } from '@/lib/url'; +import { useFields } from './useFields'; +import { useMessages } from './useMessages'; +import { useNavigation } from './useNavigation'; + +export function useFilters() { + const { formatMessage, labels } = useMessages(); + const { query } = useNavigation(); + const { fields } = useFields(); + + const operators = [ + { name: 'eq', type: 'string', label: formatMessage(labels.is) }, + { name: 'neq', type: 'string', label: formatMessage(labels.isNot) }, + { name: 'c', type: 'string', label: formatMessage(labels.contains) }, + { name: 'dnc', type: 'string', label: formatMessage(labels.doesNotContain) }, + { name: 'i', type: 'array', label: formatMessage(labels.includes) }, + { name: 'dni', type: 'array', label: formatMessage(labels.doesNotInclude) }, + { name: 't', type: 'boolean', label: formatMessage(labels.isTrue) }, + { name: 'f', type: 'boolean', label: formatMessage(labels.isFalse) }, + { name: 'eq', type: 'number', label: formatMessage(labels.is) }, + { name: 'neq', type: 'number', label: formatMessage(labels.isNot) }, + { name: 'gt', type: 'number', label: formatMessage(labels.greaterThan) }, + { name: 'lt', type: 'number', label: formatMessage(labels.lessThan) }, + { name: 'gte', type: 'number', label: formatMessage(labels.greaterThanEquals) }, + { name: 'lte', type: 'number', label: formatMessage(labels.lessThanEquals) }, + { name: 'bf', type: 'date', label: formatMessage(labels.before) }, + { name: 'af', type: 'date', label: formatMessage(labels.after) }, + { name: 'eq', type: 'uuid', label: formatMessage(labels.is) }, + ]; + + const operatorLabels = { + [OPERATORS.equals]: formatMessage(labels.is), + [OPERATORS.notEquals]: formatMessage(labels.isNot), + [OPERATORS.set]: formatMessage(labels.isSet), + [OPERATORS.notSet]: formatMessage(labels.isNotSet), + [OPERATORS.contains]: formatMessage(labels.contains), + [OPERATORS.doesNotContain]: formatMessage(labels.doesNotContain), + [OPERATORS.true]: formatMessage(labels.true), + [OPERATORS.false]: formatMessage(labels.false), + [OPERATORS.greaterThan]: formatMessage(labels.greaterThan), + [OPERATORS.lessThan]: formatMessage(labels.lessThan), + [OPERATORS.greaterThanEquals]: formatMessage(labels.greaterThanEquals), + [OPERATORS.lessThanEquals]: formatMessage(labels.lessThanEquals), + [OPERATORS.before]: formatMessage(labels.before), + [OPERATORS.after]: formatMessage(labels.after), + }; + + const typeFilters = { + string: [OPERATORS.equals, OPERATORS.notEquals, OPERATORS.contains, OPERATORS.doesNotContain], + array: [OPERATORS.contains, OPERATORS.doesNotContain], + boolean: [OPERATORS.true, OPERATORS.false], + number: [ + OPERATORS.equals, + OPERATORS.notEquals, + OPERATORS.greaterThan, + OPERATORS.lessThan, + OPERATORS.greaterThanEquals, + OPERATORS.lessThanEquals, + ], + date: [OPERATORS.before, OPERATORS.after], + uuid: [OPERATORS.equals], + }; + + const filters = Object.keys(query).reduce((arr, key) => { + if (FILTER_COLUMNS[key]) { + let operator = 'eq'; + let value = safeDecodeURIComponent(query[key]); + const label = fields.find(({ name }) => name === key)?.label; + + const match = value.match(/^([a-z]+)\.(.*)/); + + if (match) { + operator = match[1]; + value = match[2]; + } + + return arr.concat({ + name: key, + operator, + value, + label, + }); + } + return arr; + }, []); + + const getFilters = (type: string) => { + return ( + typeFilters[type]?.map((key: string | number) => ({ + type, + value: key, + label: operatorLabels[key], + })) ?? [] + ); + }; + + return { fields, operators, filters, operatorLabels, typeFilters, getFilters }; +} diff --git a/src/components/hooks/useForceUpdate.ts b/src/components/hooks/useForceUpdate.ts new file mode 100644 index 0000000..550cc5c --- /dev/null +++ b/src/components/hooks/useForceUpdate.ts @@ -0,0 +1,9 @@ +import { useCallback, useState } from 'react'; + +export function useForceUpdate() { + const [, update] = useState(Object.create(null)); + + return useCallback(() => { + update(Object.create(null)); + }, [update]); +} diff --git a/src/components/hooks/useFormat.ts b/src/components/hooks/useFormat.ts new file mode 100644 index 0000000..896fa07 --- /dev/null +++ b/src/components/hooks/useFormat.ts @@ -0,0 +1,74 @@ +import { BROWSERS, OS_NAMES } from '@/lib/constants'; +import regions from '../../../public/iso-3166-2.json'; +import { useCountryNames } from './useCountryNames'; +import { useLanguageNames } from './useLanguageNames'; +import { useLocale } from './useLocale'; +import { useMessages } from './useMessages'; + +export function useFormat() { + const { formatMessage, labels } = useMessages(); + const { locale } = useLocale(); + const { countryNames } = useCountryNames(locale); + const { languageNames } = useLanguageNames(locale); + + const formatOS = (value: string): string => { + return OS_NAMES[value] || value; + }; + + const formatBrowser = (value: string): string => { + return BROWSERS[value] || value; + }; + + const formatDevice = (value: string): string => { + return formatMessage(labels[value] || labels.unknown); + }; + + const formatCountry = (value: string): string => { + return countryNames[value] || value; + }; + + const formatRegion = (value?: string): string => { + const [country] = value?.split('-') || []; + return regions[value] ? `${regions[value]}, ${countryNames[country]}` : value; + }; + + const formatCity = (value: string, country?: string): string => { + return countryNames[country] ? `${value}, ${countryNames[country]}` : value; + }; + + const formatLanguage = (value: string): string => { + return languageNames[value?.split('-')[0]] || value; + }; + + const formatValue = (value: string, type: string, data?: Record<string, any>): string => { + switch (type) { + case 'os': + return formatOS(value); + case 'browser': + return formatBrowser(value); + case 'device': + return formatDevice(value); + case 'country': + return formatCountry(value); + case 'region': + return formatRegion(value); + case 'city': + return formatCity(value, data?.country); + case 'language': + return formatLanguage(value); + default: + return typeof value === 'string' ? value : undefined; + } + }; + + return { + formatOS, + formatBrowser, + formatDevice, + formatCountry, + formatRegion, + formatCity, + formatLanguage, + formatValue, + }; +} diff --git a/src/components/hooks/useGlobalState.ts b/src/components/hooks/useGlobalState.ts new file mode 100644 index 0000000..6f21226 --- /dev/null +++ b/src/components/hooks/useGlobalState.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +const store = create(() => ({})); + +const useGlobalState = (key: string, value?: any) => { + if (value !== undefined && store.getState()[key] === undefined) { + store.setState({ [key]: value }); + } + + return [store(state => state[key]), (value: any) => store.setState({ [key]: value })]; +}; + +export { useGlobalState }; diff --git a/src/components/hooks/useLanguageNames.ts b/src/components/hooks/useLanguageNames.ts new file mode 100644 index 0000000..0cc03d7 --- /dev/null +++ b/src/components/hooks/useLanguageNames.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { httpGet } from '@/lib/fetch'; +import enUS from '../../../public/intl/language/en-US.json'; + +const languageNames = { + 'en-US': enUS, +}; + +export function useLanguageNames(locale) { + const [list, setList] = useState(languageNames[locale] || enUS); + + async function loadData(locale) { + const { data } = await httpGet(`${process.env.basePath || ''}/intl/language/${locale}.json`); + + if (data) { + languageNames[locale] = data; + setList(languageNames[locale]); + } else { + setList(enUS); + } + } + + useEffect(() => { + if (!languageNames[locale]) { + loadData(locale); + } else { + setList(languageNames[locale]); + } + }, [locale]); + + return { languageNames: list }; +} diff --git a/src/components/hooks/useLocale.ts b/src/components/hooks/useLocale.ts new file mode 100644 index 0000000..3eb669e --- /dev/null +++ b/src/components/hooks/useLocale.ts @@ -0,0 +1,60 @@ +import { useEffect } from 'react'; +import { LOCALE_CONFIG } from '@/lib/constants'; +import { httpGet } from '@/lib/fetch'; +import { getDateLocale, getTextDirection } from '@/lib/lang'; +import { setItem } from '@/lib/storage'; +import { setLocale, useApp } from '@/store/app'; +import enUS from '../../../public/intl/country/en-US.json'; +import { useForceUpdate } from './useForceUpdate'; + +const messages = { + 'en-US': enUS, +}; + +const selector = (state: { locale: string }) => state.locale; + +export function useLocale() { + const locale = useApp(selector); + const forceUpdate = useForceUpdate(); + const dir = getTextDirection(locale); + const dateLocale = getDateLocale(locale); + + async function loadMessages(locale: string) { + const { data } = await httpGet(`${process.env.basePath || ''}/intl/messages/${locale}.json`); + + messages[locale] = data; + } + + async function saveLocale(value: string) { + if (!messages[value]) { + await loadMessages(value); + } + + setItem(LOCALE_CONFIG, value); + + document.getElementById('__next')?.setAttribute('dir', getTextDirection(value)); + + if (locale !== value) { + setLocale(value); + } else { + forceUpdate(); + } + } + + useEffect(() => { + if (!messages[locale]) { + saveLocale(locale); + } + }, [locale]); + + useEffect(() => { + const url = new URL(window?.location?.href); + const locale = url.searchParams.get('locale'); + + if (locale) { + saveLocale(locale); + } + }, []); + + return { locale, saveLocale, messages, dir, dateLocale }; +} diff --git a/src/components/hooks/useMessages.ts b/src/components/hooks/useMessages.ts new file mode 100644 index 0000000..d5bc242 --- /dev/null +++ b/src/components/hooks/useMessages.ts @@ -0,0 +1,48 @@ +import { FormattedMessage, type MessageDescriptor, useIntl } from 'react-intl'; +import { labels, messages } from '@/components/messages'; +import type { ApiError } from '@/lib/types'; + +type FormatMessage = ( + descriptor: MessageDescriptor, + values?: Record<string, string | number | boolean | null | undefined>, + opts?: any, +) => string | null; + +interface UseMessages { + formatMessage: FormatMessage; + messages: typeof messages; + labels: typeof labels; + getMessage: (id: string) => string; + getErrorMessage: (error: ApiError) => string | undefined; + FormattedMessage: typeof FormattedMessage; +} + +export function useMessages(): UseMessages { + const intl = useIntl(); + + const getMessage = (id: string) => { + const message = Object.values(messages).find(value => value.id === `message.${id}`); + + return message ? formatMessage(message) : id; + }; + + const getErrorMessage = (error: ApiError) => { + if (!error) { + return undefined; + } + + const code = error?.code; + + return code ? getMessage(code) : error?.message || 'Unknown error'; + }; + + const formatMessage = ( + descriptor: MessageDescriptor, + values?: Record<string, string | number | boolean | null | undefined>, + opts?: any, + ) => { + return descriptor ? intl.formatMessage(descriptor, values, opts) : null; + }; + + return { formatMessage, messages, labels, getMessage, getErrorMessage, FormattedMessage }; +} diff --git a/src/components/hooks/useMobile.ts b/src/components/hooks/useMobile.ts new file mode 100644 index 0000000..6b40f3d --- /dev/null +++ b/src/components/hooks/useMobile.ts @@ -0,0 +1,9 @@ +import { useBreakpoint } from '@umami/react-zen'; + +export function useMobile() { + const breakpoint = useBreakpoint(); + const isMobile = ['xs', 'sm', 'md'].includes(breakpoint); + const isPhone = ['xs', 'sm'].includes(breakpoint); + + return { breakpoint, isMobile, isPhone }; +} diff --git a/src/components/hooks/useModified.ts b/src/components/hooks/useModified.ts new file mode 100644 index 0000000..ea88888 --- /dev/null +++ b/src/components/hooks/useModified.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand'; + +const store = create(() => ({})); + +export function touch(key: string) { + store.setState({ [key]: Date.now() }); +} + +export function useModified(key?: string) { + const modified = store(state => state?.[key]); + + return { modified, touch }; +} diff --git a/src/components/hooks/useNavigation.ts b/src/components/hooks/useNavigation.ts new file mode 100644 index 0000000..0a18ac7 --- /dev/null +++ b/src/components/hooks/useNavigation.ts @@ -0,0 +1,43 @@ +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { buildPath } from '@/lib/url'; + +export function useNavigation() { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [, teamId] = pathname.match(/\/teams\/([a-f0-9-]+)/) || []; + const [, websiteId] = pathname.match(/\/websites\/([a-f0-9-]+)/) || []; + const [queryParams, setQueryParams] = useState(Object.fromEntries(searchParams)); + + const updateParams = (params?: Record<string, string | number>) => { + return buildPath(pathname, { ...queryParams, ...params }); + }; + + const replaceParams = (params?: Record<string, string | number>) => { + return buildPath(pathname, params); + }; + + const renderUrl = (path: string, params?: Record<string, string | number> | false) => { + return buildPath( + teamId ? `/teams/${teamId}${path}` : path, + params === false ? {} : { ...queryParams, ...params }, + ); + }; + + useEffect(() => { + setQueryParams(Object.fromEntries(searchParams)); + }, [searchParams.toString()]); + + return { + router, + pathname, + searchParams, + query: queryParams, + teamId, + websiteId, + updateParams, + replaceParams, + renderUrl, + }; +} diff --git a/src/components/hooks/usePageParameters.ts b/src/components/hooks/usePageParameters.ts new file mode 100644 index 0000000..42cf391 --- /dev/null +++ b/src/components/hooks/usePageParameters.ts @@ -0,0 +1,16 @@ +import { useMemo } from 'react'; +import { useNavigation } from './useNavigation'; + +export function usePageParameters() { + const { + query: { page, pageSize, search }, + } = useNavigation(); + + return useMemo(() => { + return { + page, + pageSize, + search, + }; + }, [page, pageSize, search]); +} diff --git a/src/components/hooks/usePagedQuery.ts b/src/components/hooks/usePagedQuery.ts new file mode 100644 index 0000000..c818de6 --- /dev/null +++ b/src/components/hooks/usePagedQuery.ts @@ -0,0 +1,27 @@ +import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query'; +import type { PageResult } from '@/lib/types'; +import { useApi } from './useApi'; +import { useNavigation } from './useNavigation'; + +export function usePagedQuery<TData = any, TError = Error>({ + queryKey, + queryFn, + ...options +}: Omit< + UseQueryOptions<PageResult<TData>, TError, PageResult<TData>, readonly unknown[]>, + 'queryFn' | 'queryKey' +> & { + queryKey: readonly unknown[]; + queryFn: (params?: object) => Promise<PageResult<TData>> | PageResult<TData>; +}): UseQueryResult<PageResult<TData>, TError> { + const { + query: { page, search }, + } = useNavigation(); + const { useQuery } = useApi(); + + return useQuery<PageResult<TData>, TError>({ + queryKey: [...queryKey, page, search] as const, + queryFn: () => queryFn({ page, search }), + ...options, + }); +} diff --git a/src/components/hooks/useRegionNames.ts b/src/components/hooks/useRegionNames.ts new file mode 100644 index 0000000..57dcc41 --- /dev/null +++ b/src/components/hooks/useRegionNames.ts @@ -0,0 +1,22 @@ +import regions from '../../../public/iso-3166-2.json'; +import { useCountryNames } from './useCountryNames'; + +export function useRegionNames(locale: string) { + const { countryNames } = useCountryNames(locale); + + const getRegionName = (regionCode: string, countryCode?: string) => { + if (!countryCode) { + return regions[regionCode]; + } + + if (!regionCode) { + return null; + } + + const region = regionCode?.includes('-') ? regionCode : `${countryCode}-${regionCode}`; + + return regions[region] ? `${regions[region]}, ${countryNames[countryCode]}` : region; + }; + + return { regionNames: regions, getRegionName }; +} diff --git a/src/components/hooks/useSlug.ts b/src/components/hooks/useSlug.ts new file mode 100644 index 0000000..f795dfe --- /dev/null +++ b/src/components/hooks/useSlug.ts @@ -0,0 +1,14 @@ +import { useConfig } from '@/components/hooks/useConfig'; +import { LINKS_URL, PIXELS_URL } from '@/lib/constants'; + +export function useSlug(type: 'link' | 'pixel') { + const { linksUrl, pixelsUrl } = useConfig(); + + const hostUrl = type === 'link' ? linksUrl || LINKS_URL : pixelsUrl || PIXELS_URL; + + const getSlugUrl = (slug: string) => { + return `${hostUrl}/${slug}`; + }; + + return { getSlugUrl, hostUrl }; +} diff --git a/src/components/hooks/useSticky.ts b/src/components/hooks/useSticky.ts new file mode 100644 index 0000000..ef9fb36 --- /dev/null +++ b/src/components/hooks/useSticky.ts @@ -0,0 +1,25 @@ +import { useEffect, useRef, useState } from 'react'; + +export function useSticky({ enabled = true, threshold = 1 }) { + const [isSticky, setIsSticky] = useState(false); + const ref = useRef(null); + + useEffect(() => { + let observer: IntersectionObserver | undefined; + // eslint-disable-next-line no-undef + const handler: IntersectionObserverCallback = ([entry]) => + setIsSticky(entry.intersectionRatio < threshold); + + if (enabled && ref.current) { + observer = new IntersectionObserver(handler, { threshold: [threshold] }); + observer.observe(ref.current); + } + return () => { + if (observer) { + observer.disconnect(); + } + }; + }, [ref, enabled, threshold]); + + return { ref, isSticky }; +} diff --git a/src/components/hooks/useTimezone.ts b/src/components/hooks/useTimezone.ts new file mode 100644 index 0000000..ef25539 --- /dev/null +++ b/src/components/hooks/useTimezone.ts @@ -0,0 +1,95 @@ +import { formatInTimeZone, utcToZonedTime, zonedTimeToUtc } from 'date-fns-tz'; +import { TIMEZONE_CONFIG, TIMEZONE_LEGACY } from '@/lib/constants'; +import { getTimezone } from '@/lib/date'; +import { setItem } from '@/lib/storage'; +import { setTimezone, useApp } from '@/store/app'; +import { useLocale } from './useLocale'; + +const selector = (state: { timezone: string }) => state.timezone; + +export function useTimezone() { + const timezone = useApp(selector); + const localTimeZone = getTimezone(); + const { dateLocale } = useLocale(); + + const saveTimezone = (value: string) => { + setItem(TIMEZONE_CONFIG, value); + setTimezone(value); + }; + + const formatTimezoneDate = (date: string, pattern: string) => { + return formatInTimeZone( + /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3})?Z$/.test(date) + ? date + : `${date.split(' ').join('T')}Z`, + timezone, + pattern, + { locale: dateLocale }, + ); + }; + + const formatSeriesTimezone = (data: any, column: string, timezone: string) => { + return data.map(item => { + const date = new Date(item[column]); + + const format = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + + const parts = format.formatToParts(date); + const get = type => parts.find(p => p.type === type)?.value; + + const year = get('year'); + const month = get('month'); + const day = get('day'); + const hour = get('hour'); + const minute = get('minute'); + const second = get('second'); + + return { + ...item, + [column]: `${year}-${month}-${day} ${hour}:${minute}:${second}`, + }; + }); + }; + + const toUtc = (date: Date | string | number) => { + return zonedTimeToUtc(date, timezone); + }; + + const fromUtc = (date: Date | string | number) => { + return utcToZonedTime(date, timezone); + }; + + const localToUtc = (date: Date | string | number) => { + return zonedTimeToUtc(date, localTimeZone); + }; + + const localFromUtc = (date: Date | string | number) => { + return utcToZonedTime(date, localTimeZone); + }; + + const canonicalizeTimezone = (timezone: string): string => { + return TIMEZONE_LEGACY[timezone] ?? timezone; + }; + + return { + timezone, + localTimeZone, + toUtc, + fromUtc, + localToUtc, + localFromUtc, + saveTimezone, + formatTimezoneDate, + formatSeriesTimezone, + canonicalizeTimezone, + }; +} diff --git a/src/components/icons.ts b/src/components/icons.ts new file mode 100644 index 0000000..fe433d5 --- /dev/null +++ b/src/components/icons.ts @@ -0,0 +1 @@ +export * from 'lucide-react'; diff --git a/src/components/input/ActionSelect.tsx b/src/components/input/ActionSelect.tsx new file mode 100644 index 0000000..616ee34 --- /dev/null +++ b/src/components/input/ActionSelect.tsx @@ -0,0 +1,18 @@ +import { ListItem, Select } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; + +export interface ActionSelectProps { + value?: string; + onChange?: (value: string) => void; +} + +export function ActionSelect({ value = 'path', onChange }: ActionSelectProps) { + const { formatMessage, labels } = useMessages(); + + return ( + <Select value={value} onChange={onChange}> + <ListItem id="path">{formatMessage(labels.viewedPage)}</ListItem> + <ListItem id="event">{formatMessage(labels.triggeredEvent)}</ListItem> + </Select> + ); +} diff --git a/src/components/input/CurrencySelect.tsx b/src/components/input/CurrencySelect.tsx new file mode 100644 index 0000000..2b6045b --- /dev/null +++ b/src/components/input/CurrencySelect.tsx @@ -0,0 +1,34 @@ +import { ListItem, Select } from '@umami/react-zen'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { CURRENCIES } from '@/lib/constants'; + +export function CurrencySelect({ value, onChange }) { + const { formatMessage, labels } = useMessages(); + const [search, setSearch] = useState(''); + + return ( + <Select + items={CURRENCIES} + label={formatMessage(labels.currency)} + value={value} + defaultValue={value} + onChange={onChange} + listProps={{ style: { maxHeight: 300 } }} + onSearch={setSearch} + allowSearch + > + {CURRENCIES.map(({ id, name }) => { + if (search && !`${id}${name}`.toLowerCase().includes(search)) { + return null; + } + + return ( + <ListItem key={id} id={id}> + {id} — {name} + </ListItem> + ); + }).filter(n => n)} + </Select> + ); +} diff --git a/src/components/input/DateFilter.tsx b/src/components/input/DateFilter.tsx new file mode 100644 index 0000000..2e17529 --- /dev/null +++ b/src/components/input/DateFilter.tsx @@ -0,0 +1,141 @@ +import { Dialog, ListItem, ListSeparator, Modal, Select, type SelectProps } from '@umami/react-zen'; +import { endOfYear } from 'date-fns'; +import { Fragment, type Key, useState } from 'react'; +import { DateDisplay } from '@/components/common/DateDisplay'; +import { useMessages, useMobile } from '@/components/hooks'; +import { DatePickerForm } from '@/components/metrics/DatePickerForm'; +import { parseDateRange } from '@/lib/date'; + +export interface DateFilterProps extends SelectProps { + value?: string; + onChange?: (value: string) => void; + showAllTime?: boolean; + renderDate?: boolean; + placement?: any; +} + +export function DateFilter({ + value, + onChange, + showAllTime, + renderDate, + placement = 'bottom', + ...props +}: DateFilterProps) { + const { formatMessage, labels } = useMessages(); + const [showPicker, setShowPicker] = useState(false); + const { startDate, endDate } = parseDateRange(value) || {}; + const { isMobile } = useMobile(); + + const options = [ + { label: formatMessage(labels.today), value: '0day' }, + { + label: formatMessage(labels.lastHours, { x: '24' }), + value: '24hour', + }, + { + label: formatMessage(labels.thisWeek), + value: '0week', + divider: true, + }, + { + label: formatMessage(labels.lastDays, { x: '7' }), + value: '7day', + }, + { + label: formatMessage(labels.thisMonth), + value: '0month', + divider: true, + }, + { + label: formatMessage(labels.lastDays, { x: '30' }), + value: '30day', + }, + { + label: formatMessage(labels.lastDays, { x: '90' }), + value: '90day', + }, + { label: formatMessage(labels.thisYear), value: '0year' }, + { + label: formatMessage(labels.lastMonths, { x: '6' }), + value: '6month', + divider: true, + }, + { + label: formatMessage(labels.lastMonths, { x: '12' }), + value: '12month', + }, + showAllTime && { + label: formatMessage(labels.allTime), + value: 'all', + divider: true, + }, + { + label: formatMessage(labels.customRange), + value: 'custom', + divider: true, + }, + ] + .filter(n => n) + .map((a, id) => ({ ...a, id })); + + const handleChange = (value: Key) => { + if (value === 'custom') { + setShowPicker(true); + return; + } + onChange(value.toString()); + }; + + const handlePickerChange = (value: string) => { + setShowPicker(false); + onChange(value.toString()); + }; + + const renderValue = ({ defaultChildren }) => { + return value?.startsWith('range') || renderDate ? ( + <DateDisplay startDate={startDate} endDate={endDate} /> + ) : ( + defaultChildren + ); + }; + + const selectedValue = value.endsWith(':all') ? 'all' : value; + + return ( + <> + <Select + {...props} + value={selectedValue} + placeholder={formatMessage(labels.selectDate)} + onChange={handleChange} + renderValue={renderValue} + popoverProps={{ placement }} + isFullscreen={isMobile} + > + {options.map(({ label, value, divider }: any) => { + return ( + <Fragment key={label}> + {divider && <ListSeparator />} + <ListItem id={value}>{label}</ListItem> + </Fragment> + ); + })} + </Select> + {showPicker && ( + <Modal isOpen={true}> + <Dialog> + <DatePickerForm + startDate={startDate} + endDate={endDate} + minDate={new Date(2000, 0, 1)} + maxDate={endOfYear(new Date())} + onChange={handlePickerChange} + onClose={() => setShowPicker(false)} + /> + </Dialog> + </Modal> + )} + </> + ); +} diff --git a/src/components/input/DialogButton.tsx b/src/components/input/DialogButton.tsx new file mode 100644 index 0000000..7527226 --- /dev/null +++ b/src/components/input/DialogButton.tsx @@ -0,0 +1,64 @@ +import { + Button, + type ButtonProps, + Dialog, + type DialogProps, + DialogTrigger, + IconLabel, + Modal, +} from '@umami/react-zen'; +import type { CSSProperties, ReactNode } from 'react'; +import { useMobile } from '@/components/hooks'; + +export interface DialogButtonProps extends Omit<ButtonProps, 'children'> { + icon?: ReactNode; + label?: ReactNode; + title?: ReactNode; + width?: string; + height?: string; + minWidth?: string; + minHeight?: string; + children?: DialogProps['children']; +} + +export function DialogButton({ + icon, + label, + title, + width, + height, + minWidth, + minHeight, + children, + ...props +}: DialogButtonProps) { + const { isMobile } = useMobile(); + const style: CSSProperties = { + width, + height, + minWidth, + minHeight, + maxHeight: 'calc(100dvh - 40px)', + padding: '32px', + }; + + if (isMobile) { + style.width = '100%'; + style.height = '100%'; + style.maxHeight = '100%'; + style.overflowY = 'auto'; + } + + return ( + <DialogTrigger> + <Button {...props}> + <IconLabel icon={icon} label={label} /> + </Button> + <Modal placement={isMobile ? 'fullscreen' : 'center'}> + <Dialog variant={isMobile ? 'sheet' : undefined} title={title || label} style={style}> + {children} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/components/input/DownloadButton.tsx b/src/components/input/DownloadButton.tsx new file mode 100644 index 0000000..5df3305 --- /dev/null +++ b/src/components/input/DownloadButton.tsx @@ -0,0 +1,42 @@ +import { Button, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import Papa from 'papaparse'; +import { useMessages } from '@/components/hooks'; +import { Download } from '@/components/icons'; + +export function DownloadButton({ + filename = 'data', + data, +}: { + filename?: string; + data?: any; + onClick?: () => void; +}) { + const { formatMessage, labels } = useMessages(); + + const handleClick = async () => { + downloadCsv(`${filename}.csv`, Papa.unparse(data)); + }; + + return ( + <TooltipTrigger delay={0}> + <Button variant="quiet" onClick={handleClick} isDisabled={!data || data.length === 0}> + <Icon> + <Download /> + </Icon> + </Button> + <Tooltip>{formatMessage(labels.download)}</Tooltip> + </TooltipTrigger> + ); +} + +function downloadCsv(filename: string, data: any) { + const blob = new Blob([data], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + + URL.revokeObjectURL(url); +} diff --git a/src/components/input/ExportButton.tsx b/src/components/input/ExportButton.tsx new file mode 100644 index 0000000..7b65a57 --- /dev/null +++ b/src/components/input/ExportButton.tsx @@ -0,0 +1,64 @@ +import { Icon, LoadingButton, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import { useSearchParams } from 'next/navigation'; +import { useState } from 'react'; +import { useApi, useMessages } from '@/components/hooks'; +import { useDateParameters } from '@/components/hooks/useDateParameters'; +import { useFilterParameters } from '@/components/hooks/useFilterParameters'; +import { Download } from '@/components/icons'; + +export function ExportButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const [isLoading, setIsLoading] = useState(false); + const date = useDateParameters(); + const filters = useFilterParameters(); + const searchParams = useSearchParams(); + const { get } = useApi(); + + const handleClick = async () => { + setIsLoading(true); + + const { zip } = await get(`/websites/${websiteId}/export`, { + ...date, + ...filters, + ...searchParams, + format: 'json', + }); + + await loadZip(zip); + + setIsLoading(false); + }; + + return ( + <TooltipTrigger delay={0}> + <LoadingButton + variant="quiet" + showText={!isLoading} + isLoading={isLoading} + onClick={handleClick} + > + <Icon> + <Download /> + </Icon> + </LoadingButton> + <Tooltip>{formatMessage(labels.download)}</Tooltip> + </TooltipTrigger> + ); +} + +async function loadZip(zip: string) { + const binary = atob(zip); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + const blob = new Blob([bytes], { type: 'application/zip' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = 'download.zip'; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/components/input/FieldFilters.tsx b/src/components/input/FieldFilters.tsx new file mode 100644 index 0000000..2174068 --- /dev/null +++ b/src/components/input/FieldFilters.tsx @@ -0,0 +1,117 @@ +import { + Button, + Column, + Grid, + Icon, + List, + ListItem, + Menu, + MenuItem, + MenuTrigger, + Popover, + Row, +} from '@umami/react-zen'; +import { endOfDay, subMonths } from 'date-fns'; +import type { Key } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { FilterRecord } from '@/components/common/FilterRecord'; +import { useFields, useMessages, useMobile } from '@/components/hooks'; +import { Plus } from '@/components/icons'; + +export interface FieldFiltersProps { + websiteId: string; + value?: { name: string; operator: string; value: string }[]; + exclude?: string[]; + onChange?: (data: any) => void; +} + +export function FieldFilters({ websiteId, value, exclude = [], onChange }: FieldFiltersProps) { + const { formatMessage, messages } = useMessages(); + const { fields } = useFields(); + const startDate = subMonths(endOfDay(new Date()), 6); + const endDate = endOfDay(new Date()); + const { isMobile } = useMobile(); + + const updateFilter = (name: string, props: Record<string, any>) => { + onChange(value.map(filter => (filter.name === name ? { ...filter, ...props } : filter))); + }; + + const handleAdd = (name: Key) => { + onChange(value.concat({ name: name.toString(), operator: 'eq', value: '' })); + }; + + const handleChange = (name: string, value: Key) => { + updateFilter(name, { value }); + }; + + const handleSelect = (name: string, operator: Key) => { + updateFilter(name, { operator }); + }; + + const handleRemove = (name: string) => { + onChange(value.filter(filter => filter.name !== name)); + }; + + return ( + <Grid columns={{ xs: '1fr', md: '180px 1fr' }} overflow="hidden" gapY="6"> + <Row display={{ xs: 'flex', md: 'none' }}> + <MenuTrigger> + <Button> + <Icon> + <Plus /> + </Icon> + </Button> + <Popover placement={isMobile ? 'left' : 'bottom start'} shouldFlip> + <Menu + onAction={handleAdd} + style={{ maxHeight: 'calc(100vh - 2rem)', overflowY: 'auto' }} + > + {fields + .filter(({ name }) => !exclude.includes(name)) + .map(field => { + const isDisabled = !!value.find(({ name }) => name === field.name); + return ( + <MenuItem key={field.name} id={field.name} isDisabled={isDisabled}> + {field.label} + </MenuItem> + ); + })} + </Menu> + </Popover> + </MenuTrigger> + </Row> + <Column display={{ xs: 'none', md: 'flex' }} border="right" paddingRight="3" marginRight="6"> + <List onAction={handleAdd}> + {fields + .filter(({ name }) => !exclude.includes(name)) + .map(field => { + const isDisabled = !!value.find(({ name }) => name === field.name); + return ( + <ListItem key={field.name} id={field.name} isDisabled={isDisabled}> + {field.label} + </ListItem> + ); + })} + </List> + </Column> + <Column overflow="auto" gapY="4" style={{ contain: 'layout' }}> + {value.map(filter => { + return ( + <FilterRecord + key={filter.name} + websiteId={websiteId} + type={filter.name} + startDate={startDate} + endDate={endDate} + {...filter} + onSelect={handleSelect} + onRemove={handleRemove} + onChange={handleChange} + /> + ); + })} + {!value.length && <Empty message={formatMessage(messages.nothingSelected)} />} + </Column> + </Grid> + ); +} diff --git a/src/components/input/FilterBar.tsx b/src/components/input/FilterBar.tsx new file mode 100644 index 0000000..5a52e56 --- /dev/null +++ b/src/components/input/FilterBar.tsx @@ -0,0 +1,155 @@ +import { + Button, + Dialog, + DialogTrigger, + Icon, + Modal, + Row, + Text, + Tooltip, + TooltipTrigger, +} from '@umami/react-zen'; +import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm'; +import { + useFilters, + useFormat, + useMessages, + useNavigation, + useWebsiteSegmentQuery, +} from '@/components/hooks'; +import { Bookmark, X } from '@/components/icons'; +import { isSearchOperator } from '@/lib/params'; + +export function FilterBar({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + const { formatValue } = useFormat(); + const { + router, + pathname, + updateParams, + replaceParams, + query: { segment, cohort }, + } = useNavigation(); + const { filters, operatorLabels } = useFilters(); + const { data, isLoading } = useWebsiteSegmentQuery(websiteId, segment || cohort); + const canSaveSegment = filters.length > 0 && !segment && !cohort && !pathname.includes('/share'); + + const handleCloseFilter = (param: string) => { + router.push(updateParams({ [param]: undefined })); + }; + + const handleResetFilter = () => { + router.push(replaceParams()); + }; + + const handleSegmentRemove = (type: string) => { + router.push(updateParams({ [type]: undefined })); + }; + + if (!filters.length && !segment && !cohort) { + return null; + } + + return ( + <Row gap alignItems="center" justifyContent="space-between" padding="2" backgroundColor="3"> + <Row alignItems="center" gap="2" wrap="wrap"> + {segment && !isLoading && ( + <FilterItem + name="segment" + label={formatMessage(labels.segment)} + value={data?.name || segment} + operator={operatorLabels.eq} + onRemove={() => handleSegmentRemove('segment')} + /> + )} + {cohort && !isLoading && ( + <FilterItem + name="cohort" + label={formatMessage(labels.cohort)} + value={data?.name || cohort} + operator={operatorLabels.eq} + onRemove={() => handleSegmentRemove('cohort')} + /> + )} + {filters.map(filter => { + const { name, label, operator, value } = filter; + const paramValue = isSearchOperator(operator) ? value : formatValue(value, name); + + return ( + <FilterItem + key={name} + name={name} + label={label} + operator={operatorLabels[operator]} + value={paramValue} + onRemove={(name: string) => handleCloseFilter(name)} + /> + ); + })} + </Row> + <Row alignItems="center"> + <DialogTrigger> + {canSaveSegment && ( + <TooltipTrigger delay={0}> + <Button variant="zero"> + <Icon> + <Bookmark /> + </Icon> + </Button> + <Tooltip> + <Text>{formatMessage(labels.saveSegment)}</Text> + </Tooltip> + </TooltipTrigger> + )} + <Modal> + <Dialog title={formatMessage(labels.segment)} style={{ width: 800, minHeight: 300 }}> + {({ close }) => { + return <SegmentEditForm websiteId={websiteId} onClose={close} filters={filters} />; + }} + </Dialog> + </Modal> + </DialogTrigger> + <TooltipTrigger delay={0}> + <Button variant="zero" onPress={handleResetFilter}> + <Icon> + <X /> + </Icon> + </Button> + <Tooltip> + <Text>{formatMessage(labels.clearAll)}</Text> + </Tooltip> + </TooltipTrigger> + </Row> + </Row> + ); +} + +const FilterItem = ({ name, label, operator, value, onRemove }) => { + return ( + <Row + border + padding="2" + color + backgroundColor + borderRadius + alignItems="center" + justifyContent="space-between" + theme="dark" + > + <Row alignItems="center" gap="4"> + <Row alignItems="center" gap="2"> + <Text color="12" weight="bold"> + {label} + </Text> + <Text color="11">{operator}</Text> + <Text color="12" weight="bold"> + {value} + </Text> + </Row> + <Icon onClick={() => onRemove(name)} size="xs" style={{ cursor: 'pointer' }}> + <X /> + </Icon> + </Row> + </Row> + ); +}; diff --git a/src/components/input/FilterButtons.tsx b/src/components/input/FilterButtons.tsx new file mode 100644 index 0000000..ff37fb1 --- /dev/null +++ b/src/components/input/FilterButtons.tsx @@ -0,0 +1,33 @@ +import { Box, ToggleGroup, ToggleGroupItem } from '@umami/react-zen'; +import { useState } from 'react'; + +export interface FilterButtonsProps { + items: { id: string; label: string }[]; + value: string; + onChange?: (value: string) => void; +} + +export function FilterButtons({ items, value, onChange }: FilterButtonsProps) { + const [selected, setSelected] = useState(value); + + const handleChange = (value: string) => { + setSelected(value); + onChange?.(value); + }; + + return ( + <Box> + <ToggleGroup + value={[selected]} + onChange={e => handleChange(e[0])} + disallowEmptySelection={true} + > + {items.map(({ id, label }) => ( + <ToggleGroupItem key={id} id={id}> + {label} + </ToggleGroupItem> + ))} + </ToggleGroup> + </Box> + ); +} diff --git a/src/components/input/FilterEditForm.tsx b/src/components/input/FilterEditForm.tsx new file mode 100644 index 0000000..44f4384 --- /dev/null +++ b/src/components/input/FilterEditForm.tsx @@ -0,0 +1,95 @@ +import { Button, Column, Row, Tab, TabList, TabPanel, Tabs } from '@umami/react-zen'; +import { useState } from 'react'; +import { useFilters, useMessages, useMobile, useNavigation } from '@/components/hooks'; +import { FieldFilters } from '@/components/input/FieldFilters'; +import { SegmentFilters } from '@/components/input/SegmentFilters'; + +export interface FilterEditFormProps { + websiteId?: string; + onChange?: (params: { filters: any[]; segment?: string; cohort?: string }) => void; + onClose?: () => void; +} + +export function FilterEditForm({ websiteId, onChange, onClose }: FilterEditFormProps) { + const { + query: { segment, cohort }, + pathname, + } = useNavigation(); + const { filters } = useFilters(); + const { formatMessage, labels } = useMessages(); + const [currentFilters, setCurrentFilters] = useState(filters); + const [currentSegment, setCurrentSegment] = useState(segment); + const [currentCohort, setCurrentCohort] = useState(cohort); + const { isMobile } = useMobile(); + const excludeFilters = pathname.includes('/pixels') || pathname.includes('/links'); + + const handleReset = () => { + setCurrentFilters([]); + setCurrentSegment(undefined); + setCurrentCohort(undefined); + }; + + const handleSave = () => { + onChange?.({ + filters: currentFilters.filter(f => f.value), + segment: currentSegment, + cohort: currentCohort, + }); + onClose?.(); + }; + + const handleSegmentChange = (id: string, type: string) => { + setCurrentSegment(type === 'segment' ? id : undefined); + setCurrentCohort(type === 'cohort' ? id : undefined); + }; + + return ( + <Column width={isMobile ? 'auto' : '800px'} gap="6"> + <Column minHeight="500px"> + <Tabs> + <TabList> + <Tab id="fields">{formatMessage(labels.fields)}</Tab> + {!excludeFilters && ( + <> + <Tab id="segments">{formatMessage(labels.segments)}</Tab> + <Tab id="cohorts">{formatMessage(labels.cohorts)}</Tab> + </> + )} + </TabList> + <TabPanel id="fields"> + <FieldFilters + websiteId={websiteId} + value={currentFilters} + onChange={setCurrentFilters} + exclude={excludeFilters ? ['path', 'title', 'hostname', 'tag', 'event'] : []} + /> + </TabPanel> + <TabPanel id="segments"> + <SegmentFilters + websiteId={websiteId} + segmentId={currentSegment} + onChange={handleSegmentChange} + /> + </TabPanel> + <TabPanel id="cohorts"> + <SegmentFilters + type="cohort" + websiteId={websiteId} + segmentId={currentCohort} + onChange={handleSegmentChange} + /> + </TabPanel> + </Tabs> + </Column> + <Row alignItems="center" justifyContent="space-between" gap> + <Button onPress={handleReset}>{formatMessage(labels.reset)}</Button> + <Row alignItems="center" justifyContent="flex-end" gridColumn="span 2" gap> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <Button variant="primary" onPress={handleSave}> + {formatMessage(labels.apply)} + </Button> + </Row> + </Row> + </Column> + ); +} diff --git a/src/components/input/LanguageButton.tsx b/src/components/input/LanguageButton.tsx new file mode 100644 index 0000000..ac43dcb --- /dev/null +++ b/src/components/input/LanguageButton.tsx @@ -0,0 +1,41 @@ +import { Button, Dialog, Grid, Icon, MenuTrigger, Popover, Text } from '@umami/react-zen'; +import { Globe } from 'lucide-react'; +import { useLocale } from '@/components/hooks'; +import { languages } from '@/lib/lang'; + +export function LanguageButton() { + const { locale, saveLocale } = useLocale(); + const items = Object.keys(languages).map(key => ({ ...languages[key], value: key })); + + function handleSelect(value: string) { + saveLocale(value); + } + + return ( + <MenuTrigger key="language"> + <Button variant="quiet"> + <Icon> + <Globe /> + </Icon> + </Button> + <Popover placement="bottom end"> + <Dialog variant="menu"> + <Grid columns="repeat(3, minmax(200px, 1fr))" overflow="hidden"> + {items.map(({ value, label }) => { + return ( + <Button key={value} variant="quiet" onPress={() => handleSelect(value)}> + <Text + weight={value === locale ? 'bold' : 'medium'} + color={value === locale ? undefined : 'muted'} + > + {label} + </Text> + </Button> + ); + })} + </Grid> + </Dialog> + </Popover> + </MenuTrigger> + ); +} diff --git a/src/components/input/LookupField.tsx b/src/components/input/LookupField.tsx new file mode 100644 index 0000000..c1d419f --- /dev/null +++ b/src/components/input/LookupField.tsx @@ -0,0 +1,65 @@ +import { ComboBox, type ComboBoxProps, ListItem, Loading, useDebounce } from '@umami/react-zen'; +import { endOfDay, subMonths } from 'date-fns'; +import { type SetStateAction, useMemo, useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { useMessages, useWebsiteValuesQuery } from '@/components/hooks'; + +export interface LookupFieldProps extends ComboBoxProps { + websiteId: string; + type: string; + value: string; + onChange: (value: string) => void; +} + +export function LookupField({ websiteId, type, value, onChange, ...props }: LookupFieldProps) { + const { formatMessage, messages } = useMessages(); + const [search, setSearch] = useState(value); + const searchValue = useDebounce(search, 300); + const startDate = subMonths(endOfDay(new Date()), 6); + const endDate = endOfDay(new Date()); + + const { data, isLoading } = useWebsiteValuesQuery({ + websiteId, + type, + search: searchValue, + startDate, + endDate, + }); + + const items: string[] = useMemo(() => { + return data?.map(({ value }) => value) || []; + }, [data]); + + const handleSearch = (value: SetStateAction<string>) => { + setSearch(value); + }; + + return ( + <ComboBox + aria-label="LookupField" + {...props} + items={items} + inputValue={value} + onInputChange={value => { + handleSearch(value); + onChange?.(value); + }} + formValue="text" + allowsEmptyCollection + allowsCustomValue + renderEmptyState={() => + isLoading ? ( + <Loading placement="center" icon="dots" /> + ) : ( + <Empty message={formatMessage(messages.noResultsFound)} /> + ) + } + > + {items.map(item => ( + <ListItem key={item} id={item}> + {item} + </ListItem> + ))} + </ComboBox> + ); +} diff --git a/src/components/input/MenuButton.tsx b/src/components/input/MenuButton.tsx new file mode 100644 index 0000000..bac307f --- /dev/null +++ b/src/components/input/MenuButton.tsx @@ -0,0 +1,32 @@ +import { Button, DialogTrigger, Icon, Menu, Popover } from '@umami/react-zen'; +import type { Key, ReactNode } from 'react'; +import { Ellipsis } from '@/components/icons'; + +export function MenuButton({ + children, + onAction, + isDisabled, +}: { + children: ReactNode; + onAction?: (action: string) => void; + isDisabled?: boolean; +}) { + const handleAction = (key: Key) => { + onAction?.(key.toString()); + }; + + return ( + <DialogTrigger> + <Button variant="quiet" isDisabled={isDisabled}> + <Icon> + <Ellipsis /> + </Icon> + </Button> + <Popover placement="bottom start"> + <Menu aria-label="menu" onAction={handleAction} style={{ minWidth: '140px' }}> + {children} + </Menu> + </Popover> + </DialogTrigger> + ); +} diff --git a/src/components/input/MobileMenuButton.tsx b/src/components/input/MobileMenuButton.tsx new file mode 100644 index 0000000..5e59cbb --- /dev/null +++ b/src/components/input/MobileMenuButton.tsx @@ -0,0 +1,17 @@ +import { Button, Dialog, type DialogProps, DialogTrigger, Icon, Modal } from '@umami/react-zen'; +import { Menu } from '@/components/icons'; + +export function MobileMenuButton(props: DialogProps) { + return ( + <DialogTrigger> + <Button> + <Icon> + <Menu /> + </Icon> + </Button> + <Modal placement="left" offset="80px"> + <Dialog variant="sheet" {...props} /> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/components/input/MonthFilter.tsx b/src/components/input/MonthFilter.tsx new file mode 100644 index 0000000..dec64b0 --- /dev/null +++ b/src/components/input/MonthFilter.tsx @@ -0,0 +1,18 @@ +import { useDateRange, useNavigation } from '@/components/hooks'; +import { getMonthDateRangeValue } from '@/lib/date'; +import { MonthSelect } from './MonthSelect'; + +export function MonthFilter() { + const { router, updateParams } = useNavigation(); + const { + dateRange: { startDate }, + } = useDateRange(); + + const handleMonthSelect = (date: Date) => { + const range = getMonthDateRangeValue(date); + + router.push(updateParams({ date: range, offset: undefined })); + }; + + return <MonthSelect date={startDate} onChange={handleMonthSelect} />; +} diff --git a/src/components/input/MonthSelect.tsx b/src/components/input/MonthSelect.tsx new file mode 100644 index 0000000..241634e --- /dev/null +++ b/src/components/input/MonthSelect.tsx @@ -0,0 +1,47 @@ +import { ListItem, Row, Select } from '@umami/react-zen'; +import { useLocale } from '@/components/hooks'; +import { formatDate } from '@/lib/date'; + +export function MonthSelect({ date = new Date(), onChange }) { + const { locale } = useLocale(); + const month = date.getMonth(); + const year = date.getFullYear(); + const currentYear = new Date().getFullYear(); + + const months = [...Array(12)].map((_, i) => i); + const years = [...Array(10)].map((_, i) => currentYear - i); + + const handleMonthChange = (month: number) => { + const d = new Date(date); + d.setMonth(month); + onChange?.(d); + }; + const handleYearChange = (year: number) => { + const d = new Date(date); + d.setFullYear(year); + onChange?.(d); + }; + + return ( + <Row gap> + <Select value={month} onChange={handleMonthChange}> + {months.map(m => { + return ( + <ListItem id={m} key={m}> + {formatDate(new Date(year, m, 1), 'MMMM', locale)} + </ListItem> + ); + })} + </Select> + <Select value={year} onChange={handleYearChange}> + {years.map(y => { + return ( + <ListItem id={y} key={y}> + {y} + </ListItem> + ); + })} + </Select> + </Row> + ); +} diff --git a/src/components/input/NavButton.tsx b/src/components/input/NavButton.tsx new file mode 100644 index 0000000..ab77ef0 --- /dev/null +++ b/src/components/input/NavButton.tsx @@ -0,0 +1,188 @@ +import { + Column, + Icon, + IconLabel, + Menu, + MenuItem, + MenuSection, + MenuSeparator, + MenuTrigger, + Popover, + Pressable, + Row, + SubmenuTrigger, + Text, +} from '@umami/react-zen'; +import { ArrowRight } from 'lucide-react'; +import type { Key } from 'react'; +import { + useConfig, + useLoginQuery, + useMessages, + useMobile, + useNavigation, +} from '@/components/hooks'; +import { + BookText, + ChevronRight, + ExternalLink, + LifeBuoy, + LockKeyhole, + LogOut, + Settings, + User, + Users, +} from '@/components/icons'; +import { Switch } from '@/components/svg'; +import { DOCS_URL, LAST_TEAM_CONFIG } from '@/lib/constants'; +import { removeItem } from '@/lib/storage'; + +export interface TeamsButtonProps { + showText?: boolean; + onAction?: (id: any) => void; +} + +export function NavButton({ showText = true }: TeamsButtonProps) { + const { user } = useLoginQuery(); + const { cloudMode } = useConfig(); + const { formatMessage, labels } = useMessages(); + const { teamId, router } = useNavigation(); + const { isMobile } = useMobile(); + const team = user?.teams?.find(({ id }) => id === teamId); + const selectedKeys = new Set([teamId || 'user']); + const label = teamId ? team?.name : user.username; + + const getUrl = (url: string) => { + return cloudMode ? `${process.env.cloudUrl}${url}` : url; + }; + + const handleAction = async (key: Key) => { + if (key === 'user') { + removeItem(LAST_TEAM_CONFIG); + if (cloudMode) { + window.location.href = '/'; + } else { + router.push('/'); + } + } + }; + + return ( + <MenuTrigger> + <Pressable> + <Row + alignItems="center" + justifyContent="space-between" + flexGrow={1} + padding + border + borderRadius + shadow="1" + maxHeight="40px" + role="button" + style={{ cursor: 'pointer', textWrap: 'nowrap', overflow: 'hidden', outline: 'none' }} + > + <Row alignItems="center" position="relative" gap maxHeight="40px"> + <Icon>{teamId ? <Users /> : <User />}</Icon> + {showText && <Text>{label}</Text>} + </Row> + {showText && ( + <Icon rotate={90} size="sm"> + <ChevronRight /> + </Icon> + )} + </Row> + </Pressable> + <Popover placement="bottom start"> + <Column minWidth="300px"> + <Menu autoFocus="last"> + <SubmenuTrigger> + <MenuItem id="teams" showChecked={false} showSubMenuIcon> + <IconLabel icon={<Switch />} label={formatMessage(labels.switchAccount)} /> + </MenuItem> + <Popover placement={isMobile ? 'bottom start' : 'right top'}> + <Column minWidth="300px"> + <Menu selectionMode="single" selectedKeys={selectedKeys} onAction={handleAction}> + <MenuSection title={formatMessage(labels.myAccount)}> + <MenuItem id="user"> + <IconLabel icon={<User />} label={user.username} /> + </MenuItem> + </MenuSection> + <MenuSeparator /> + <MenuSection title={formatMessage(labels.teams)}> + {user?.teams?.map(({ id, name }) => ( + <MenuItem key={id} id={id} href={getUrl(`/teams/${id}`)}> + <IconLabel icon={<Users />}> + <Text wrap="nowrap">{name}</Text> + </IconLabel> + </MenuItem> + ))} + {user?.teams?.length === 0 && ( + <MenuItem id="manage-teams"> + <a href="/settings/teams" style={{ width: '100%' }}> + <Row alignItems="center" justifyContent="space-between" gap> + <Text align="center">Manage teams</Text> + <Icon> + <ArrowRight /> + </Icon> + </Row> + </a> + </MenuItem> + )} + </MenuSection> + </Menu> + </Column> + </Popover> + </SubmenuTrigger> + <MenuSeparator /> + <MenuItem + id="settings" + href={getUrl('/settings')} + icon={<Settings />} + label={formatMessage(labels.settings)} + /> + {cloudMode && ( + <> + <MenuItem + id="docs" + href={DOCS_URL} + target="_blank" + icon={<BookText />} + label={formatMessage(labels.documentation)} + > + <Icon color="muted"> + <ExternalLink /> + </Icon> + </MenuItem> + <MenuItem + id="support" + href={getUrl('/settings/support')} + icon={<LifeBuoy />} + label={formatMessage(labels.support)} + /> + </> + )} + {!cloudMode && user.isAdmin && ( + <> + <MenuSeparator /> + <MenuItem + id="/admin" + href="/admin" + icon={<LockKeyhole />} + label={formatMessage(labels.admin)} + /> + </> + )} + <MenuSeparator /> + <MenuItem + id="logout" + href={getUrl('/logout')} + icon={<LogOut />} + label={formatMessage(labels.logout)} + /> + </Menu> + </Column> + </Popover> + </MenuTrigger> + ); +} diff --git a/src/components/input/PanelButton.tsx b/src/components/input/PanelButton.tsx new file mode 100644 index 0000000..500c40c --- /dev/null +++ b/src/components/input/PanelButton.tsx @@ -0,0 +1,19 @@ +import { Button, type ButtonProps, Icon } from '@umami/react-zen'; +import { useGlobalState } from '@/components/hooks'; +import { PanelLeft } from '@/components/icons'; + +export function PanelButton(props: ButtonProps) { + const [isCollapsed, setIsCollapsed] = useGlobalState('sidenav-collapsed'); + return ( + <Button + onPress={() => setIsCollapsed(!isCollapsed)} + variant="zero" + {...props} + style={{ padding: 0 }} + > + <Icon strokeColor="muted"> + <PanelLeft /> + </Icon> + </Button> + ); +} diff --git a/src/components/input/PreferencesButton.tsx b/src/components/input/PreferencesButton.tsx new file mode 100644 index 0000000..710a7fa --- /dev/null +++ b/src/components/input/PreferencesButton.tsx @@ -0,0 +1,32 @@ +import { Button, Column, DialogTrigger, Icon, Label, Popover } from '@umami/react-zen'; +import { DateRangeSetting } from '@/app/(main)/settings/preferences/DateRangeSetting'; +import { TimezoneSetting } from '@/app/(main)/settings/preferences/TimezoneSetting'; +import { Panel } from '@/components/common/Panel'; +import { useMessages } from '@/components/hooks'; +import { Settings } from '@/components/icons'; + +export function PreferencesButton() { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogTrigger> + <Button variant="quiet"> + <Icon> + <Settings /> + </Icon> + </Button> + <Popover placement="bottom end"> + <Panel gap="3"> + <Column> + <Label>{formatMessage(labels.timezone)}</Label> + <TimezoneSetting /> + </Column> + <Column> + <Label>{formatMessage(labels.defaultDateRange)}</Label> + <DateRangeSetting /> + </Column> + </Panel> + </Popover> + </DialogTrigger> + ); +} diff --git a/src/components/input/ProfileButton.tsx b/src/components/input/ProfileButton.tsx new file mode 100644 index 0000000..505cd88 --- /dev/null +++ b/src/components/input/ProfileButton.tsx @@ -0,0 +1,74 @@ +import { + Button, + Icon, + Menu, + MenuItem, + MenuSection, + MenuSeparator, + MenuTrigger, + Popover, + Row, + Text, +} from '@umami/react-zen'; +import { Fragment } from 'react'; +import { useLoginQuery, useMessages, useNavigation } from '@/components/hooks'; +import { LockKeyhole, LogOut, UserCircle } from '@/components/icons'; + +export function ProfileButton() { + const { formatMessage, labels } = useMessages(); + const { user } = useLoginQuery(); + const { renderUrl } = useNavigation(); + + const items = [ + { + id: 'settings', + label: formatMessage(labels.profile), + path: renderUrl('/settings/profile'), + icon: <UserCircle />, + }, + user.isAdmin && + !process.env.cloudMode && { + id: 'admin', + label: formatMessage(labels.admin), + path: '/admin', + icon: <LockKeyhole />, + }, + { + id: 'logout', + label: formatMessage(labels.logout), + path: '/logout', + icon: <LogOut />, + separator: true, + }, + ].filter(n => n); + + return ( + <MenuTrigger> + <Button data-test="button-profile" variant="quiet"> + <Icon> + <UserCircle /> + </Icon> + </Button> + <Popover placement="bottom end"> + <Menu autoFocus="last"> + <MenuSection title={user.username}> + <MenuSeparator /> + {items.map(({ id, path, label, icon, separator }) => { + return ( + <Fragment key={id}> + {separator && <MenuSeparator />} + <MenuItem id={id} href={path}> + <Row alignItems="center" gap> + <Icon>{icon}</Icon> + <Text>{label}</Text> + </Row> + </MenuItem> + </Fragment> + ); + })} + </MenuSection> + </Menu> + </Popover> + </MenuTrigger> + ); +} diff --git a/src/components/input/RefreshButton.tsx b/src/components/input/RefreshButton.tsx new file mode 100644 index 0000000..b52f830 --- /dev/null +++ b/src/components/input/RefreshButton.tsx @@ -0,0 +1,32 @@ +import { Icon, LoadingButton, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import { useDateRange, useMessages } from '@/components/hooks'; +import { RefreshCw } from '@/components/icons'; +import { setWebsiteDateRange } from '@/store/websites'; + +export function RefreshButton({ + websiteId, + isLoading, +}: { + websiteId: string; + isLoading?: boolean; +}) { + const { formatMessage, labels } = useMessages(); + const { dateRange } = useDateRange(); + + function handleClick() { + if (!isLoading && dateRange) { + setWebsiteDateRange(websiteId, dateRange); + } + } + + return ( + <TooltipTrigger> + <LoadingButton isLoading={isLoading} onPress={handleClick}> + <Icon> + <RefreshCw /> + </Icon> + </LoadingButton> + <Tooltip>{formatMessage(labels.refresh)}</Tooltip> + </TooltipTrigger> + ); +} diff --git a/src/components/input/ReportEditButton.tsx b/src/components/input/ReportEditButton.tsx new file mode 100644 index 0000000..b333077 --- /dev/null +++ b/src/components/input/ReportEditButton.tsx @@ -0,0 +1,99 @@ +import { + AlertDialog, + Button, + Icon, + Menu, + MenuItem, + MenuTrigger, + Modal, + Popover, + Row, + Text, +} from '@umami/react-zen'; +import { type ReactNode, useState } from 'react'; +import { useMessages } from '@/components/hooks'; +import { useDeleteQuery } from '@/components/hooks/queries/useDeleteQuery'; +import { Edit, MoreHorizontal, Trash } from '@/components/icons'; + +export function ReportEditButton({ + id, + name, + type, + children, + onDelete, +}: { + id: string; + name: string; + type: string; + onDelete?: () => void; + children: ({ close }: { close: () => void }) => ReactNode; +}) { + const { formatMessage, labels, messages } = useMessages(); + const [showEdit, setShowEdit] = useState(false); + const [showDelete, setShowDelete] = useState(false); + const { mutateAsync, touch } = useDeleteQuery(`/reports/${id}`); + + const handleAction = (id: any) => { + if (id === 'edit') { + setShowEdit(true); + } else if (id === 'delete') { + setShowDelete(true); + } + }; + + const handleClose = () => { + setShowEdit(false); + setShowDelete(false); + }; + + const handleDelete = async () => { + await mutateAsync(null, { + onSuccess: async () => { + touch(`reports:${type}`); + setShowDelete(false); + onDelete?.(); + }, + }); + }; + + return ( + <> + <MenuTrigger> + <Button variant="quiet"> + <Icon> + <MoreHorizontal /> + </Icon> + </Button> + <Popover placement="bottom"> + <Menu onAction={handleAction}> + <MenuItem id="edit"> + <Icon> + <Edit /> + </Icon> + <Text>{formatMessage(labels.edit)}</Text> + </MenuItem> + <MenuItem id="delete"> + <Icon> + <Trash /> + </Icon> + <Text>{formatMessage(labels.delete)}</Text> + </MenuItem> + </Menu> + </Popover> + </MenuTrigger> + <Modal isOpen={showEdit || showDelete} isDismissable={true}> + {showEdit && children({ close: handleClose })} + {showDelete && ( + <AlertDialog + title={formatMessage(labels.delete)} + onConfirm={handleDelete} + onCancel={handleClose} + isDanger + > + <Row gap="1">{formatMessage(messages.confirmDelete, { target: name })}</Row> + </AlertDialog> + )} + </Modal> + </> + ); +} diff --git a/src/components/input/SegmentFilters.tsx b/src/components/input/SegmentFilters.tsx new file mode 100644 index 0000000..f03a1de --- /dev/null +++ b/src/components/input/SegmentFilters.tsx @@ -0,0 +1,42 @@ +import { IconLabel, List, ListItem } from '@umami/react-zen'; +import { Empty } from '@/components/common/Empty'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useWebsiteSegmentsQuery } from '@/components/hooks'; +import { ChartPie, UserPlus } from '@/components/icons'; + +export interface SegmentFiltersProps { + websiteId: string; + segmentId: string; + type?: string; + onChange?: (id: string, type: string) => void; +} + +export function SegmentFilters({ + websiteId, + segmentId, + type = 'segment', + onChange, +}: SegmentFiltersProps) { + const { data, isLoading, isFetching } = useWebsiteSegmentsQuery(websiteId, { type }); + + const handleChange = (id: string) => { + onChange?.(id, type); + }; + + return ( + <LoadingPanel data={data} isLoading={isLoading} isFetching={isFetching} overflowY="auto"> + {data?.data?.length === 0 && <Empty />} + <List selectionMode="single" value={[segmentId]} onChange={id => handleChange(id[0])}> + {data?.data?.map(item => { + return ( + <ListItem key={item.id} id={item.id}> + <IconLabel icon={type === 'segment' ? <ChartPie /> : <UserPlus />}> + {item.name} + </IconLabel> + </ListItem> + ); + })} + </List> + </LoadingPanel> + ); +} diff --git a/src/components/input/SegmentSaveButton.tsx b/src/components/input/SegmentSaveButton.tsx new file mode 100644 index 0000000..5f6cac1 --- /dev/null +++ b/src/components/input/SegmentSaveButton.tsx @@ -0,0 +1,26 @@ +import { Button, Dialog, DialogTrigger, Icon, Modal, Text } from '@umami/react-zen'; +import { SegmentEditForm } from '@/app/(main)/websites/[websiteId]/segments/SegmentEditForm'; +import { useMessages } from '@/components/hooks'; +import { Plus } from '@/components/icons'; + +export function SegmentSaveButton({ websiteId }: { websiteId: string }) { + const { formatMessage, labels } = useMessages(); + + return ( + <DialogTrigger> + <Button variant="primary"> + <Icon> + <Plus /> + </Icon> + <Text>{formatMessage(labels.segment)}</Text> + </Button> + <Modal> + <Dialog title={formatMessage(labels.segment)} style={{ width: 800 }}> + {({ close }) => { + return <SegmentEditForm websiteId={websiteId} onClose={close} />; + }} + </Dialog> + </Modal> + </DialogTrigger> + ); +} diff --git a/src/components/input/SettingsButton.tsx b/src/components/input/SettingsButton.tsx new file mode 100644 index 0000000..bd51fb5 --- /dev/null +++ b/src/components/input/SettingsButton.tsx @@ -0,0 +1,84 @@ +import { + Button, + Icon, + Menu, + MenuItem, + MenuSection, + MenuSeparator, + MenuTrigger, + Popover, +} from '@umami/react-zen'; +import type { Key } from 'react'; +import { useConfig, useLoginQuery, useMessages, useNavigation } from '@/components/hooks'; +import { + BookText, + ExternalLink, + LifeBuoy, + LockKeyhole, + LogOut, + Settings, + UserCircle, +} from '@/components/icons'; +import { DOCS_URL } from '@/lib/constants'; + +export function SettingsButton() { + const { formatMessage, labels } = useMessages(); + const { user } = useLoginQuery(); + const { router } = useNavigation(); + const { cloudMode } = useConfig(); + + const handleAction = (id: Key) => { + const url = id.toString(); + + if (cloudMode) { + if (url === '/docs') { + window.open(DOCS_URL, '_blank'); + } else { + window.location.href = url; + } + } else { + router.push(url); + } + }; + + return ( + <MenuTrigger> + <Button data-test="button-profile" variant="quiet" autoFocus={false}> + <Icon> + <UserCircle /> + </Icon> + </Button> + <Popover placement="bottom end"> + <Menu autoFocus="last" onAction={handleAction}> + <MenuSection title={user.username}> + <MenuSeparator /> + <MenuItem id="/settings" icon={<Settings />} label={formatMessage(labels.settings)} /> + {!cloudMode && user.isAdmin && ( + <MenuItem id="/admin" icon={<LockKeyhole />} label={formatMessage(labels.admin)} /> + )} + {cloudMode && ( + <> + <MenuItem + id="/docs" + icon={<BookText />} + label={formatMessage(labels.documentation)} + > + <Icon color="muted"> + <ExternalLink /> + </Icon> + </MenuItem> + <MenuItem + id="/settings/support" + icon={<LifeBuoy />} + label={formatMessage(labels.support)} + /> + </> + )} + <MenuSeparator /> + <MenuItem id="/logout" icon={<LogOut />} label={formatMessage(labels.logout)} /> + </MenuSection> + </Menu> + </Popover> + </MenuTrigger> + ); +} diff --git a/src/components/input/WebsiteDateFilter.tsx b/src/components/input/WebsiteDateFilter.tsx new file mode 100644 index 0000000..18b4f13 --- /dev/null +++ b/src/components/input/WebsiteDateFilter.tsx @@ -0,0 +1,102 @@ +import { Button, Icon, ListItem, Row, Select, Text } from '@umami/react-zen'; +import { isAfter } from 'date-fns'; +import { useMemo } from 'react'; +import { useDateRange, useDateRangeQuery, useMessages, useNavigation } from '@/components/hooks'; +import { ChevronRight } from '@/components/icons'; +import { getDateRangeValue } from '@/lib/date'; +import { DateFilter } from './DateFilter'; + +export interface WebsiteDateFilterProps { + websiteId: string; + compare?: string; + showAllTime?: boolean; + showButtons?: boolean; + allowCompare?: boolean; +} + +export function WebsiteDateFilter({ + websiteId, + showAllTime = true, + showButtons = true, + allowCompare, +}: WebsiteDateFilterProps) { + const { dateRange, isAllTime, isCustomRange } = useDateRange(); + const { formatMessage, labels } = useMessages(); + const { + router, + updateParams, + query: { compare = 'prev', offset = 0 }, + } = useNavigation(); + const disableForward = isAllTime || isAfter(dateRange.endDate, new Date()); + const showCompare = allowCompare && !isAllTime; + + const websiteDateRange = useDateRangeQuery(websiteId); + + const handleChange = (date: string) => { + if (date === 'all') { + router.push( + updateParams({ + date: `${getDateRangeValue(websiteDateRange.startDate, websiteDateRange.endDate)}:all`, + offset: undefined, + }), + ); + } else { + router.push(updateParams({ date, offset: undefined })); + } + }; + + const handleIncrement = increment => { + router.push(updateParams({ offset: Number(offset) + increment })); + }; + const handleSelect = (compare: any) => { + router.push(updateParams({ compare })); + }; + + const dateValue = useMemo(() => { + return offset !== 0 + ? getDateRangeValue(dateRange.startDate, dateRange.endDate) + : dateRange.value; + }, [dateRange]); + + return ( + <Row wrap="wrap" gap> + {showButtons && !isAllTime && !isCustomRange && ( + <Row gap="1"> + <Button onPress={() => handleIncrement(-1)} variant="outline"> + <Icon rotate={180}> + <ChevronRight /> + </Icon> + </Button> + <Button onPress={() => handleIncrement(1)} variant="outline" isDisabled={disableForward}> + <Icon> + <ChevronRight /> + </Icon> + </Button> + </Row> + )} + <Row minWidth="200px"> + <DateFilter + value={dateValue} + onChange={handleChange} + showAllTime={showAllTime} + renderDate={+offset !== 0} + /> + </Row> + {showCompare && ( + <Row alignItems="center" gap> + <Text weight="bold">VS</Text> + <Row width="200px"> + <Select + value={compare} + onChange={handleSelect} + popoverProps={{ style: { width: 200 } }} + > + <ListItem id="prev">{formatMessage(labels.previousPeriod)}</ListItem> + <ListItem id="yoy">{formatMessage(labels.previousYear)}</ListItem> + </Select> + </Row> + </Row> + )} + </Row> + ); +} diff --git a/src/components/input/WebsiteFilterButton.tsx b/src/components/input/WebsiteFilterButton.tsx new file mode 100644 index 0000000..7db850a --- /dev/null +++ b/src/components/input/WebsiteFilterButton.tsx @@ -0,0 +1,32 @@ +import { useMessages, useNavigation } from '@/components/hooks'; +import { ListFilter } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { FilterEditForm } from '@/components/input/FilterEditForm'; +import { filtersArrayToObject } from '@/lib/params'; + +export function WebsiteFilterButton({ + websiteId, +}: { + websiteId: string; + position?: 'bottom' | 'top' | 'left' | 'right'; + alignment?: 'end' | 'center' | 'start'; +}) { + const { formatMessage, labels } = useMessages(); + const { updateParams, router } = useNavigation(); + + const handleChange = ({ filters, segment, cohort }: any) => { + const params = filtersArrayToObject(filters); + + const url = updateParams({ ...params, segment, cohort }); + + router.push(url); + }; + + return ( + <DialogButton icon={<ListFilter />} label={formatMessage(labels.filter)} variant="outline"> + {({ close }) => { + return <FilterEditForm websiteId={websiteId} onChange={handleChange} onClose={close} />; + }} + </DialogButton> + ); +} diff --git a/src/components/input/WebsiteSelect.tsx b/src/components/input/WebsiteSelect.tsx new file mode 100644 index 0000000..8d81eb9 --- /dev/null +++ b/src/components/input/WebsiteSelect.tsx @@ -0,0 +1,74 @@ +import { ListItem, Row, Select, type SelectProps, Text } from '@umami/react-zen'; +import { useState } from 'react'; +import { Empty } from '@/components/common/Empty'; +import { + useLoginQuery, + useMessages, + useUserWebsitesQuery, + useWebsiteQuery, +} from '@/components/hooks'; + +export function WebsiteSelect({ + websiteId, + teamId, + onChange, + includeTeams, + ...props +}: { + websiteId?: string; + teamId?: string; + includeTeams?: boolean; +} & SelectProps) { + const { formatMessage, messages } = useMessages(); + const { data: website } = useWebsiteQuery(websiteId); + const [name, setName] = useState<string>(website?.name); + const [search, setSearch] = useState(''); + const { user } = useLoginQuery(); + const { data, isLoading } = useUserWebsitesQuery( + { userId: user?.id, teamId }, + { search, pageSize: 10, includeTeams }, + ); + const listItems: { id: string; name: string }[] = data?.data || []; + + const handleSearch = (value: string) => { + setSearch(value); + }; + + const handleOpenChange = () => { + setSearch(''); + }; + + const handleChange = (id: string) => { + setName(listItems.find(item => item.id === id)?.name); + onChange(id); + }; + + const renderValue = () => { + return ( + <Row maxWidth="160px"> + <Text truncate>{name}</Text> + </Row> + ); + }; + + return ( + <Select + {...props} + items={listItems} + value={websiteId} + isLoading={isLoading} + allowSearch={true} + searchValue={search} + onSearch={handleSearch} + onChange={handleChange} + onOpenChange={handleOpenChange} + renderValue={renderValue} + listProps={{ + renderEmptyState: () => <Empty message={formatMessage(messages.noResultsFound)} />, + style: { maxHeight: '400px' }, + }} + > + {({ id, name }: any) => <ListItem key={id}>{name}</ListItem>} + </Select> + ); +} diff --git a/src/components/messages.ts b/src/components/messages.ts new file mode 100644 index 0000000..0438c06 --- /dev/null +++ b/src/components/messages.ts @@ -0,0 +1,518 @@ +import { defineMessages } from 'react-intl'; + +export const labels = defineMessages({ + ok: { id: 'label.ok', defaultMessage: 'OK' }, + unknown: { id: 'label.unknown', defaultMessage: 'Unknown' }, + required: { id: 'label.required', defaultMessage: 'Required' }, + save: { id: 'label.save', defaultMessage: 'Save' }, + cancel: { id: 'label.cancel', defaultMessage: 'Cancel' }, + continue: { id: 'label.continue', defaultMessage: 'Continue' }, + delete: { id: 'label.delete', defaultMessage: 'Delete' }, + leave: { id: 'label.leave', defaultMessage: 'Leave' }, + users: { id: 'label.users', defaultMessage: 'Users' }, + createUser: { id: 'label.create-user', defaultMessage: 'Create user' }, + deleteUser: { id: 'label.delete-user', defaultMessage: 'Delete user' }, + username: { id: 'label.username', defaultMessage: 'Username' }, + password: { id: 'label.password', defaultMessage: 'Password' }, + role: { id: 'label.role', defaultMessage: 'Role' }, + user: { id: 'label.user', defaultMessage: 'User' }, + viewOnly: { id: 'label.view-only', defaultMessage: 'View only' }, + manage: { id: 'label.manage', defaultMessage: 'Manage' }, + admin: { id: 'label.admin', defaultMessage: 'Admin' }, + confirm: { id: 'label.confirm', defaultMessage: 'Confirm' }, + details: { id: 'label.details', defaultMessage: 'Details' }, + website: { id: 'label.website', defaultMessage: 'Website' }, + websites: { id: 'label.websites', defaultMessage: 'Websites' }, + myWebsites: { id: 'label.my-websites', defaultMessage: 'My websites' }, + teamWebsites: { id: 'label.team-websites', defaultMessage: 'Team websites' }, + created: { id: 'label.created', defaultMessage: 'Created' }, + createdBy: { id: 'label.created-by', defaultMessage: 'Created By' }, + edit: { id: 'label.edit', defaultMessage: 'Edit' }, + name: { id: 'label.name', defaultMessage: 'Name' }, + manager: { id: 'label.manager', defaultMessage: 'Manager' }, + member: { id: 'label.member', defaultMessage: 'Member' }, + members: { id: 'label.members', defaultMessage: 'Members' }, + accessCode: { id: 'label.access-code', defaultMessage: 'Access code' }, + teamId: { id: 'label.team-id', defaultMessage: 'Team ID' }, + team: { id: 'label.team', defaultMessage: 'Team' }, + teamName: { id: 'label.team-name', defaultMessage: 'Team name' }, + regenerate: { id: 'label.regenerate', defaultMessage: 'Regenerate' }, + remove: { id: 'label.remove', defaultMessage: 'Remove' }, + join: { id: 'label.join', defaultMessage: 'Join' }, + createTeam: { id: 'label.create-team', defaultMessage: 'Create team' }, + joinTeam: { id: 'label.join-team', defaultMessage: 'Join team' }, + settings: { id: 'label.settings', defaultMessage: 'Settings' }, + owner: { id: 'label.owner', defaultMessage: 'Owner' }, + teamOwner: { id: 'label.team-owner', defaultMessage: 'Team owner' }, + teamManager: { id: 'label.team-manager', defaultMessage: 'Team manager' }, + teamMember: { id: 'label.team-member', defaultMessage: 'Team member' }, + teamViewOnly: { id: 'label.team-view-only', defaultMessage: 'Team view only' }, + enableShareUrl: { id: 'label.enable-share-url', defaultMessage: 'Enable share URL' }, + data: { id: 'label.data', defaultMessage: 'Data' }, + trackingCode: { id: 'label.tracking-code', defaultMessage: 'Tracking code' }, + shareUrl: { id: 'label.share-url', defaultMessage: 'Share URL' }, + action: { id: 'label.action', defaultMessage: 'Action' }, + actions: { id: 'label.actions', defaultMessage: 'Actions' }, + domain: { id: 'label.domain', defaultMessage: 'Domain' }, + websiteId: { id: 'label.website-id', defaultMessage: 'Website ID' }, + resetWebsite: { id: 'label.reset-website', defaultMessage: 'Reset website' }, + deleteWebsite: { id: 'label.delete-website', defaultMessage: 'Delete website' }, + transferWebsite: { id: 'label.transfer-website', defaultMessage: 'Transfer website' }, + deleteReport: { id: 'label.delete-report', defaultMessage: 'Delete report' }, + reset: { id: 'label.reset', defaultMessage: 'Reset' }, + addWebsite: { id: 'label.add-website', defaultMessage: 'Add website' }, + addMember: { id: 'label.add-member', defaultMessage: 'Add member' }, + editMember: { id: 'label.edit-member', defaultMessage: 'Edit member' }, + removeMember: { id: 'label.remove-member', defaultMessage: 'Remove member' }, + addDescription: { id: 'label.add-description', defaultMessage: 'Add description' }, + changePassword: { id: 'label.change-password', defaultMessage: 'Change password' }, + currentPassword: { id: 'label.current-password', defaultMessage: 'Current password' }, + newPassword: { id: 'label.new-password', defaultMessage: 'New password' }, + confirmPassword: { id: 'label.confirm-password', defaultMessage: 'Confirm password' }, + timezone: { id: 'label.timezone', defaultMessage: 'Timezone' }, + defaultDateRange: { id: 'label.default-date-range', defaultMessage: 'Default date range' }, + language: { id: 'label.language', defaultMessage: 'Language' }, + theme: { id: 'label.theme', defaultMessage: 'Theme' }, + profile: { id: 'label.profile', defaultMessage: 'Profile' }, + profiles: { id: 'label.profiles', defaultMessage: 'Profiles' }, + dashboard: { id: 'label.dashboard', defaultMessage: 'Dashboard' }, + more: { id: 'label.more', defaultMessage: 'More' }, + realtime: { id: 'label.realtime', defaultMessage: 'Realtime' }, + queries: { id: 'label.queries', defaultMessage: 'Queries' }, + teams: { id: 'label.teams', defaultMessage: 'Teams' }, + teamSettings: { id: 'label.team-settings', defaultMessage: 'Team settings' }, + analytics: { id: 'label.analytics', defaultMessage: 'Analytics' }, + login: { id: 'label.login', defaultMessage: 'Login' }, + logout: { id: 'label.logout', defaultMessage: 'Logout' }, + singleDay: { id: 'label.single-day', defaultMessage: 'Single day' }, + dateRange: { id: 'label.date-range', defaultMessage: 'Date range' }, + viewDetails: { id: 'label.view-details', defaultMessage: 'View details' }, + deleteTeam: { id: 'label.delete-team', defaultMessage: 'Delete team' }, + leaveTeam: { id: 'label.leave-team', defaultMessage: 'Leave team' }, + refresh: { id: 'label.refresh', defaultMessage: 'Refresh' }, + page: { id: 'label.page', defaultMessage: 'Page' }, + pages: { id: 'label.pages', defaultMessage: 'Pages' }, + entry: { id: 'label.entry', defaultMessage: 'Entry' }, + exit: { id: 'label.exit', defaultMessage: 'Exit' }, + referrers: { id: 'label.referrers', defaultMessage: 'Referrers' }, + screen: { id: 'label.screen', defaultMessage: 'Screen' }, + screens: { id: 'label.screens', defaultMessage: 'Screens' }, + browsers: { id: 'label.browsers', defaultMessage: 'Browsers' }, + os: { id: 'label.os', defaultMessage: 'OS' }, + devices: { id: 'label.devices', defaultMessage: 'Devices' }, + countries: { id: 'label.countries', defaultMessage: 'Countries' }, + languages: { id: 'label.languages', defaultMessage: 'Languages' }, + tags: { id: 'label.tags', defaultMessage: 'Tags' }, + segments: { id: 'label.segments', defaultMessage: 'Segments' }, + cohorts: { id: 'label.cohorts', defaultMessage: 'Cohorts' }, + count: { id: 'label.count', defaultMessage: 'Count' }, + average: { id: 'label.average', defaultMessage: 'Average' }, + sum: { id: 'label.sum', defaultMessage: 'Sum' }, + event: { id: 'label.event', defaultMessage: 'Event' }, + events: { id: 'label.events', defaultMessage: 'Events' }, + eventName: { id: 'label.event-name', defaultMessage: 'Event name' }, + query: { id: 'label.query', defaultMessage: 'Query' }, + queryParameters: { id: 'label.query-parameters', defaultMessage: 'Query parameters' }, + back: { id: 'label.back', defaultMessage: 'Back' }, + visitors: { id: 'label.visitors', defaultMessage: 'Visitors' }, + visits: { id: 'label.visits', defaultMessage: 'Visits' }, + filterCombined: { id: 'label.filter-combined', defaultMessage: 'Combined' }, + filterRaw: { id: 'label.filter-raw', defaultMessage: 'Raw' }, + views: { id: 'label.views', defaultMessage: 'Views' }, + none: { id: 'label.none', defaultMessage: 'None' }, + clearAll: { id: 'label.clear-all', defaultMessage: 'Clear all' }, + property: { id: 'label.property', defaultMessage: 'Property' }, + today: { id: 'label.today', defaultMessage: 'Today' }, + lastHours: { id: 'label.last-hours', defaultMessage: 'Last {x} hours' }, + yesterday: { id: 'label.yesterday', defaultMessage: 'Yesterday' }, + thisWeek: { id: 'label.this-week', defaultMessage: 'This week' }, + lastDays: { id: 'label.last-days', defaultMessage: 'Last {x} days' }, + lastMonths: { id: 'label.last-months', defaultMessage: 'Last {x} months' }, + thisMonth: { id: 'label.this-month', defaultMessage: 'This month' }, + thisYear: { id: 'label.this-year', defaultMessage: 'This year' }, + allTime: { id: 'label.all-time', defaultMessage: 'All time' }, + customRange: { id: 'label.custom-range', defaultMessage: 'Custom range' }, + selectWebsite: { id: 'label.select-website', defaultMessage: 'Select website' }, + selectRole: { id: 'label.select-role', defaultMessage: 'Select role' }, + selectDate: { id: 'label.select-date', defaultMessage: 'Select date' }, + selectFilter: { id: 'label.select-filter', defaultMessage: 'Select filter' }, + all: { id: 'label.all', defaultMessage: 'All' }, + session: { id: 'label.session', defaultMessage: 'Session' }, + sessions: { id: 'label.sessions', defaultMessage: 'Sessions' }, + distinctId: { id: 'label.distinct-id', defaultMessage: 'Distinct ID' }, + pageNotFound: { id: 'message.page-not-found', defaultMessage: 'Page not found' }, + activity: { id: 'label.activity', defaultMessage: 'Activity' }, + dismiss: { id: 'label.dismiss', defaultMessage: 'Dismiss' }, + poweredBy: { id: 'label.powered-by', defaultMessage: 'Powered by {name}' }, + pageViews: { id: 'label.page-views', defaultMessage: 'Page views' }, + uniqueVisitors: { id: 'label.unique-visitors', defaultMessage: 'Unique visitors' }, + bounceRate: { id: 'label.bounce-rate', defaultMessage: 'Bounce rate' }, + viewsPerVisit: { id: 'label.views-per-visit', defaultMessage: 'Views per visit' }, + visitDuration: { id: 'label.visit-duration', defaultMessage: 'Visit duration' }, + desktop: { id: 'label.desktop', defaultMessage: 'Desktop' }, + laptop: { id: 'label.laptop', defaultMessage: 'Laptop' }, + tablet: { id: 'label.tablet', defaultMessage: 'Tablet' }, + mobile: { id: 'label.mobile', defaultMessage: 'Mobile' }, + toggleCharts: { id: 'label.toggle-charts', defaultMessage: 'Toggle charts' }, + editDashboard: { id: 'label.edit-dashboard', defaultMessage: 'Edit dashboard' }, + title: { id: 'label.title', defaultMessage: 'Title' }, + view: { id: 'label.view', defaultMessage: 'View' }, + cities: { id: 'label.cities', defaultMessage: 'Cities' }, + regions: { id: 'label.regions', defaultMessage: 'Regions' }, + reports: { id: 'label.reports', defaultMessage: 'Reports' }, + eventData: { id: 'label.event-data', defaultMessage: 'Event data' }, + sessionData: { id: 'label.session-data', defaultMessage: 'Session data' }, + funnel: { id: 'label.funnel', defaultMessage: 'Funnel' }, + funnels: { id: 'label.funnels', defaultMessage: 'Funnels' }, + funnelDescription: { + id: 'label.funnel-description', + defaultMessage: 'Understand the conversion and drop-off rate of users.', + }, + revenue: { id: 'label.revenue', defaultMessage: 'Revenue' }, + revenueDescription: { + id: 'label.revenue-description', + defaultMessage: 'Look into your revenue data and how users are spending.', + }, + attribution: { id: 'label.attribution', defaultMessage: 'Attribution' }, + attributionDescription: { + id: 'label.attribution-description', + defaultMessage: 'See how users engage with your marketing and what drives conversions.', + }, + currency: { id: 'label.currency', defaultMessage: 'Currency' }, + model: { id: 'label.model', defaultMessage: 'Model' }, + path: { id: 'label.path', defaultMessage: 'Path' }, + paths: { id: 'label.paths', defaultMessage: 'Paths' }, + add: { id: 'label.add', defaultMessage: 'Add' }, + update: { id: 'label.update', defaultMessage: 'Update' }, + window: { id: 'label.window', defaultMessage: 'Window' }, + runQuery: { id: 'label.run-query', defaultMessage: 'Run query' }, + field: { id: 'label.field', defaultMessage: 'Field' }, + fields: { id: 'label.fields', defaultMessage: 'Fields' }, + createReport: { id: 'label.create-report', defaultMessage: 'Create report' }, + description: { id: 'label.description', defaultMessage: 'Description' }, + untitled: { id: 'label.untitled', defaultMessage: 'Untitled' }, + type: { id: 'label.type', defaultMessage: 'Type' }, + filter: { id: 'label.filter', defaultMessage: 'Filter' }, + filters: { id: 'label.filters', defaultMessage: 'Filters' }, + breakdown: { id: 'label.breakdown', defaultMessage: 'Breakdown' }, + true: { id: 'label.true', defaultMessage: 'True' }, + false: { id: 'label.false', defaultMessage: 'False' }, + is: { id: 'label.is', defaultMessage: 'Is' }, + isNot: { id: 'label.is-not', defaultMessage: 'Is not' }, + isSet: { id: 'label.is-set', defaultMessage: 'Is set' }, + isNotSet: { id: 'label.is-not-set', defaultMessage: 'Is not set' }, + greaterThan: { id: 'label.greater-than', defaultMessage: 'Greater than' }, + lessThan: { id: 'label.less-than', defaultMessage: 'Less than' }, + greaterThanEquals: { id: 'label.greater-than-equals', defaultMessage: 'Greater than or equals' }, + lessThanEquals: { id: 'label.less-than-equals', defaultMessage: 'Less than or equals' }, + contains: { id: 'label.contains', defaultMessage: 'Contains' }, + doesNotContain: { id: 'label.does-not-contain', defaultMessage: 'Does not contain' }, + includes: { id: 'label.includes', defaultMessage: 'Includes' }, + doesNotInclude: { id: 'label.does-not-include', defaultMessage: 'Does not include' }, + before: { id: 'label.before', defaultMessage: 'Before' }, + after: { id: 'label.after', defaultMessage: 'After' }, + isTrue: { id: 'label.is-true', defaultMessage: 'Is true' }, + isFalse: { id: 'label.is-false', defaultMessage: 'Is false' }, + exists: { id: 'label.exists', defaultMessage: 'Exists' }, + doesNotExist: { id: 'label.doest-not-exist', defaultMessage: 'Does not exist' }, + total: { id: 'label.total', defaultMessage: 'Total' }, + min: { id: 'label.min', defaultMessage: 'Min' }, + max: { id: 'label.max', defaultMessage: 'Max' }, + unique: { id: 'label.unique', defaultMessage: 'Unique' }, + value: { id: 'label.value', defaultMessage: 'Value' }, + overview: { id: 'label.overview', defaultMessage: 'Overview' }, + totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' }, + insight: { id: 'label.insight', defaultMessage: 'Insight' }, + insights: { id: 'label.insights', defaultMessage: 'Insights' }, + insightsDescription: { + id: 'label.insights-description', + defaultMessage: 'Dive deeper into your data by using segments and filters.', + }, + retention: { id: 'label.retention', defaultMessage: 'Retention' }, + retentionDescription: { + id: 'label.retention-description', + defaultMessage: 'Measure your website stickiness by tracking how often users return.', + }, + dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' }, + referrer: { id: 'label.referrer', defaultMessage: 'Referrer' }, + hostname: { id: 'label.hostname', defaultMessage: 'Hostname' }, + country: { id: 'label.country', defaultMessage: 'Country' }, + region: { id: 'label.region', defaultMessage: 'Region' }, + city: { id: 'label.city', defaultMessage: 'City' }, + browser: { id: 'label.browser', defaultMessage: 'Browser' }, + device: { id: 'label.device', defaultMessage: 'Device' }, + pageTitle: { id: 'label.pageTitle', defaultMessage: 'Page title' }, + tag: { id: 'label.tag', defaultMessage: 'Tag' }, + segment: { id: 'label.segment', defaultMessage: 'Segment' }, + cohort: { id: 'label.cohort', defaultMessage: 'Cohort' }, + day: { id: 'label.day', defaultMessage: 'Day' }, + date: { id: 'label.date', defaultMessage: 'Date' }, + pageOf: { id: 'label.page-of', defaultMessage: 'Page {current} of {total}' }, + create: { id: 'label.create', defaultMessage: 'Create' }, + search: { id: 'label.search', defaultMessage: 'Search' }, + numberOfRecords: { + id: 'label.number-of-records', + defaultMessage: '{x} {x, plural, one {record} other {records}}', + }, + select: { id: 'label.select', defaultMessage: 'Select' }, + myAccount: { id: 'label.my-account', defaultMessage: 'My account' }, + transfer: { id: 'label.transfer', defaultMessage: 'Transfer' }, + transactions: { id: 'label.transactions', defaultMessage: 'Transactions' }, + uniqueCustomers: { id: 'label.uniqueCustomers', defaultMessage: 'Unique Customers' }, + viewedPage: { + id: 'message.viewed-page', + defaultMessage: 'Viewed page', + }, + collectedData: { + id: 'message.collected-data', + defaultMessage: 'Collected data', + }, + triggeredEvent: { + id: 'message.triggered-event', + defaultMessage: 'Triggered event', + }, + utm: { id: 'label.utm', defaultMessage: 'UTM' }, + utmDescription: { + id: 'label.utm-description', + defaultMessage: 'Track your campaigns through UTM parameters.', + }, + conversionStep: { id: 'label.conversion-step', defaultMessage: 'Conversion step' }, + conversionRate: { id: 'label.conversion-rate', defaultMessage: 'Conversion rate' }, + steps: { id: 'label.steps', defaultMessage: 'Steps' }, + startStep: { id: 'label.start-step', defaultMessage: 'Start Step' }, + endStep: { id: 'label.end-step', defaultMessage: 'End Step' }, + addStep: { id: 'label.add-step', defaultMessage: 'Add step' }, + goal: { id: 'label.goal', defaultMessage: 'Goal' }, + goals: { id: 'label.goals', defaultMessage: 'Goals' }, + goalsDescription: { + id: 'label.goals-description', + defaultMessage: 'Track your goals for pageviews and events.', + }, + journey: { id: 'label.journey', defaultMessage: 'Journey' }, + journeys: { id: 'label.journeys', defaultMessage: 'Journeys' }, + journeyDescription: { + id: 'label.journey-description', + defaultMessage: 'Understand how users navigate through your website.', + }, + compareDates: { id: 'label.compare-dates', defaultMessage: 'Compare dates' }, + compare: { id: 'label.compare', defaultMessage: 'Compare' }, + current: { id: 'label.current', defaultMessage: 'Current' }, + previous: { id: 'label.previous', defaultMessage: 'Previous' }, + previousPeriod: { id: 'label.previous-period', defaultMessage: 'Previous period' }, + previousYear: { id: 'label.previous-year', defaultMessage: 'Previous year' }, + lastSeen: { id: 'label.last-seen', defaultMessage: 'Last seen' }, + firstSeen: { id: 'label.first-seen', defaultMessage: 'First seen' }, + properties: { id: 'label.properties', defaultMessage: 'Properties' }, + channel: { id: 'label.channel', defaultMessage: 'Channel' }, + channels: { id: 'label.channels', defaultMessage: 'Channels' }, + sources: { id: 'label.sources', defaultMessage: 'Sources' }, + medium: { id: 'label.medium', defaultMessage: 'Medium' }, + campaigns: { id: 'label.campaigns', defaultMessage: 'Campaigns' }, + content: { id: 'label.content', defaultMessage: 'Content' }, + terms: { id: 'label.terms', defaultMessage: 'Terms' }, + direct: { id: 'label.direct', defaultMessage: 'Direct' }, + referral: { id: 'label.referral', defaultMessage: 'Referral' }, + affiliate: { id: 'label.affiliate', defaultMessage: 'Affiliate' }, + email: { id: 'label.email', defaultMessage: 'Email' }, + sms: { id: 'label.sms', defaultMessage: 'SMS' }, + organicSearch: { id: 'label.organic-search', defaultMessage: 'Organic search' }, + organicSocial: { id: 'label.organic-social', defaultMessage: 'Organic social' }, + organicShopping: { id: 'label.organic-shopping', defaultMessage: 'Organic shopping' }, + organicVideo: { id: 'label.organic-video', defaultMessage: 'Organic video' }, + paidAds: { id: 'label.paid-ads', defaultMessage: 'Paid ads' }, + paidSearch: { id: 'label.paid-search', defaultMessage: 'Paid search' }, + paidSocial: { id: 'label.paid-social', defaultMessage: 'Paid social' }, + paidShopping: { id: 'label.paid-shopping', defaultMessage: 'Paid shopping' }, + paidVideo: { id: 'label.paid-video', defaultMessage: 'Paid video' }, + grouped: { id: 'label.grouped', defaultMessage: 'Grouped' }, + other: { id: 'label.other', defaultMessage: 'Other' }, + boards: { id: 'label.boards', defaultMessage: 'Boards' }, + apply: { id: 'label.apply', defaultMessage: 'Apply' }, + link: { id: 'label.link', defaultMessage: 'Link' }, + links: { id: 'label.links', defaultMessage: 'Links' }, + pixel: { id: 'label.pixel', defaultMessage: 'Pixel' }, + pixels: { id: 'label.pixels', defaultMessage: 'Pixels' }, + addBoard: { id: 'label.add-board', defaultMessage: 'Add board' }, + addLink: { id: 'label.add-link', defaultMessage: 'Add link' }, + addPixel: { id: 'label.add-pixel', defaultMessage: 'Add pixel' }, + maximize: { id: 'label.maximize', defaultMessage: 'Maximize' }, + remaining: { id: 'label.remaining', defaultMessage: 'Remaining' }, + conversion: { id: 'label.conversion', defaultMessage: 'Conversion' }, + firstClick: { id: 'label.first-click', defaultMessage: 'First click' }, + lastClick: { id: 'label.last-click', defaultMessage: 'Last click' }, + online: { id: 'label.online', defaultMessage: 'Online' }, + preferences: { id: 'label.preferences', defaultMessage: 'Preferences' }, + location: { id: 'label.location', defaultMessage: 'Location' }, + chart: { id: 'label.chart', defaultMessage: 'Chart' }, + table: { id: 'label.table', defaultMessage: 'Table' }, + download: { id: 'label.download', defaultMessage: 'Download' }, + traffic: { id: 'label.traffic', defaultMessage: 'Traffic' }, + behavior: { id: 'label.behavior', defaultMessage: 'Behavior' }, + growth: { id: 'label.growth', defaultMessage: 'Growth' }, + account: { id: 'label.account', defaultMessage: 'Account' }, + application: { id: 'label.application', defaultMessage: 'Application' }, + saveSegment: { id: 'label.save-segment', defaultMessage: 'Save as segment' }, + saveCohort: { id: 'label.save-cohort', defaultMessage: 'Save as cohort' }, + analysis: { id: 'label.analysis', defaultMessage: 'Analysis' }, + destinationUrl: { id: 'label.destination-url', defaultMessage: 'Destination URL' }, + audience: { id: 'label.audience', defaultMessage: 'Audience' }, + invalidUrl: { id: 'label.invalid-url', defaultMessage: 'Invalid URL' }, + environment: { id: 'label.environment', defaultMessage: 'Environment' }, + criteria: { id: 'label.criteria', defaultMessage: 'Criteria' }, + share: { id: 'label.share', defaultMessage: 'Share' }, + support: { id: 'label.support', defaultMessage: 'Support' }, + documentation: { id: 'label.documentation', defaultMessage: 'Documentation' }, + switchAccount: { id: 'label.switch-account', defaultMessage: 'Switch account' }, +}); + +export const messages = defineMessages({ + error: { id: 'message.error', defaultMessage: 'Something went wrong.' }, + saved: { id: 'message.saved', defaultMessage: 'Saved successfully.' }, + noUsers: { id: 'message.no-users', defaultMessage: 'There are no users.' }, + userDeleted: { id: 'message.user-deleted', defaultMessage: 'User deleted.' }, + noDataAvailable: { id: 'message.no-data-available', defaultMessage: 'No data available.' }, + nothingSelected: { id: 'message.nothing-selected', defaultMessage: 'Nothing selected.' }, + confirmReset: { + id: 'message.confirm-reset', + defaultMessage: 'Are you sure you want to reset {target}?', + }, + confirmDelete: { + id: 'message.confirm-delete', + defaultMessage: 'Are you sure you want to delete {target}?', + }, + confirmRemove: { + id: 'message.confirm-remove', + defaultMessage: 'Are you sure you want to remove {target}?', + }, + confirmLeave: { + id: 'message.confirm-leave', + defaultMessage: 'Are you sure you want to leave {target}?', + }, + minPasswordLength: { + id: 'message.min-password-length', + defaultMessage: 'Minimum length of {n} characters', + }, + noTeams: { + id: 'message.no-teams', + defaultMessage: 'You have not created any teams.', + }, + shareUrl: { + id: 'message.share-url', + defaultMessage: 'Your website stats are publicly available at the following URL:', + }, + trackingCode: { + id: 'message.tracking-code', + defaultMessage: + 'To track stats for this website, place the following code in the <head>...</head> section of your HTML.', + }, + joinTeamWarning: { + id: 'message.team-already-member', + defaultMessage: 'You are already a member of the team.', + }, + actionConfirmation: { + id: 'message.action-confirmation', + defaultMessage: 'Type {confirmation} in the box below to confirm.', + }, + resetWebsite: { + id: 'message.reset-website', + defaultMessage: 'To reset this website, type {confirmation} in the box below to confirm.', + }, + invalidDomain: { + id: 'message.invalid-domain', + defaultMessage: 'Invalid domain. Do not include http/https.', + }, + resetWebsiteWarning: { + id: 'message.reset-website-warning', + defaultMessage: + 'All statistics for this website will be deleted, but your settings will remain intact.', + }, + deleteWebsiteWarning: { + id: 'message.delete-website-warning', + defaultMessage: 'All website data will be deleted.', + }, + deleteTeamWarning: { + id: 'message.delete-team-warning', + defaultMessage: 'Deleting a team will also delete all team websites.', + }, + noResultsFound: { + id: 'message.no-results-found', + defaultMessage: 'No results found.', + }, + noWebsitesConfigured: { + id: 'message.no-websites-configured', + defaultMessage: 'You do not have any websites configured.', + }, + noTeamWebsites: { + id: 'message.no-team-websites', + defaultMessage: 'This team does not have any websites.', + }, + teamWebsitesInfo: { + id: 'message.team-websites-info', + defaultMessage: 'Websites can be viewed by anyone on the team.', + }, + noMatchPassword: { id: 'message.no-match-password', defaultMessage: 'Passwords do not match.' }, + goToSettings: { + id: 'message.go-to-settings', + defaultMessage: 'Go to settings', + }, + activeUsers: { + id: 'message.active-users', + defaultMessage: '{x} current {x, plural, one {visitor} other {visitors}}', + }, + teamNotFound: { + id: 'message.team-not-found', + defaultMessage: 'Team not found.', + }, + visitorLog: { + id: 'message.visitor-log', + defaultMessage: 'Visitor from {country} using {browser} on {os} {device}', + }, + eventLog: { + id: 'message.event-log', + defaultMessage: '{event} on {url}', + }, + incorrectUsernamePassword: { + id: 'message.incorrect-username-password', + defaultMessage: 'Incorrect username and/or password.', + }, + noEventData: { + id: 'message.no-event-data', + defaultMessage: 'No event data is available.', + }, + newVersionAvailable: { + id: 'message.new-version-available', + defaultMessage: 'A new version of Umami {version} is available!', + }, + transferWebsite: { + id: 'message.transfer-website', + defaultMessage: 'Transfer website ownership to your account or another team.', + }, + transferTeamWebsiteToUser: { + id: 'message.transfer-team-website-to-user', + defaultMessage: 'Transfer this website to your account?', + }, + transferUserWebsiteToTeam: { + id: 'message.transfer-user-website-to-team', + defaultMessage: 'Select the team to transfer this website to.', + }, + unauthorized: { + id: 'message.unauthorized', + defaultMessage: 'Unauthorized', + }, + badRequest: { + id: 'message.bad-request', + defaultMessage: 'Bad request', + }, + forbidden: { + id: 'message.forbidden', + defaultMessage: 'Forbidden', + }, + notFound: { + id: 'message.not-found', + defaultMessage: 'Not found', + }, + serverError: { + id: 'message.sever-error', + defaultMessage: 'Server error', + }, +}); diff --git a/src/components/metrics/ActiveUsers.tsx b/src/components/metrics/ActiveUsers.tsx new file mode 100644 index 0000000..a4bc7da --- /dev/null +++ b/src/components/metrics/ActiveUsers.tsx @@ -0,0 +1,39 @@ +import { StatusLight, Text } from '@umami/react-zen'; +import { useMemo } from 'react'; +import { LinkButton } from '@/components/common/LinkButton'; +import { useActyiveUsersQuery, useMessages } from '@/components/hooks'; + +export function ActiveUsers({ + websiteId, + value, + refetchInterval = 60000, +}: { + websiteId: string; + value?: number; + refetchInterval?: number; +}) { + const { formatMessage, labels } = useMessages(); + const { data } = useActyiveUsersQuery(websiteId, { refetchInterval }); + + const count = useMemo(() => { + if (websiteId) { + return data?.visitors || 0; + } + + return value !== undefined ? value : 0; + }, [data, value, websiteId]); + + if (count === 0) { + return null; + } + + return ( + <LinkButton href={`/websites/${websiteId}/realtime`} variant="quiet"> + <StatusLight variant="success"> + <Text size="2" weight="medium"> + {count} {formatMessage(labels.online)} + </Text> + </StatusLight> + </LinkButton> + ); +} diff --git a/src/components/metrics/ChangeLabel.tsx b/src/components/metrics/ChangeLabel.tsx new file mode 100644 index 0000000..192f0ff --- /dev/null +++ b/src/components/metrics/ChangeLabel.tsx @@ -0,0 +1,60 @@ +import { Icon, Row, type RowProps, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { ArrowRight } from '@/components/icons'; + +const STYLES = { + positive: { + color: `var(--success-color)`, + background: `color-mix(in srgb, var(--success-color), var(--background-color) 95%)`, + }, + negative: { + color: `var(--danger-color)`, + background: `color-mix(in srgb, var(--danger-color), var(--background-color) 95%)`, + }, + neutral: { + color: `var(--font-color-muted)`, + background: `var(--base-color-2)`, + }, +}; + +export function ChangeLabel({ + value, + size, + reverseColors, + children, + ...props +}: { + value: number; + size?: 'xs' | 'sm' | 'md' | 'lg'; + title?: string; + reverseColors?: boolean; + showPercentage?: boolean; + children?: ReactNode; +} & RowProps) { + const positive = value >= 0; + const negative = value < 0; + const neutral = value === 0 || Number.isNaN(value); + const good = reverseColors ? negative : positive; + + const style = + STYLES[good && 'positive'] || STYLES[!good && 'negative'] || STYLES[neutral && 'neutral']; + + return ( + <Row + {...props} + style={style} + alignItems="center" + alignSelf="flex-start" + paddingX="2" + paddingY="1" + gap="2" + > + {!neutral && ( + <Icon rotate={positive ? -90 : 90} size={size}> + <ArrowRight /> + </Icon> + )} + <Text>{children || value}</Text> + </Row> + ); +} diff --git a/src/components/metrics/DatePickerForm.tsx b/src/components/metrics/DatePickerForm.tsx new file mode 100644 index 0000000..59d1709 --- /dev/null +++ b/src/components/metrics/DatePickerForm.tsx @@ -0,0 +1,74 @@ +import { Button, Calendar, Column, Row, ToggleGroup, ToggleGroupItem } from '@umami/react-zen'; +import { endOfDay, isAfter, isBefore, isSameDay, startOfDay } from 'date-fns'; +import { useState } from 'react'; +import { useMessages } from '@/components/hooks'; + +const FILTER_DAY = 'filter-day'; +const FILTER_RANGE = 'filter-range'; + +export function DatePickerForm({ + startDate: defaultStartDate, + endDate: defaultEndDate, + minDate, + maxDate, + onChange, + onClose, +}) { + const [selected, setSelected] = useState<any>([ + isSameDay(defaultStartDate, defaultEndDate) ? FILTER_DAY : FILTER_RANGE, + ]); + const [date, setDate] = useState(defaultStartDate || new Date()); + const [startDate, setStartDate] = useState(defaultStartDate || new Date()); + const [endDate, setEndDate] = useState(defaultEndDate || new Date()); + const { formatMessage, labels } = useMessages(); + + const disabled = selected.includes(FILTER_DAY) + ? isAfter(minDate, date) && isBefore(maxDate, date) + : isAfter(startDate, endDate); + + const handleSave = () => { + if (selected.includes(FILTER_DAY)) { + onChange(`range:${startOfDay(date).getTime()}:${endOfDay(date).getTime()}`); + } else { + onChange(`range:${startOfDay(startDate).getTime()}:${endOfDay(endDate).getTime()}`); + } + }; + + return ( + <Column gap> + <Row justifyContent="center"> + <ToggleGroup disallowEmptySelection value={selected} onChange={setSelected}> + <ToggleGroupItem id={FILTER_DAY}>{formatMessage(labels.singleDay)}</ToggleGroupItem> + <ToggleGroupItem id={FILTER_RANGE}>{formatMessage(labels.dateRange)}</ToggleGroupItem> + </ToggleGroup> + </Row> + <Column> + {selected.includes(FILTER_DAY) && ( + <Calendar value={date} minValue={minDate} maxValue={maxDate} onChange={setDate} /> + )} + {selected.includes(FILTER_RANGE) && ( + <Row gap wrap="wrap" style={{ margin: '0 auto' }}> + <Calendar + value={startDate} + minValue={minDate} + maxValue={endDate} + onChange={setStartDate} + /> + <Calendar + value={endDate} + minValue={startDate} + maxValue={maxDate} + onChange={setEndDate} + /> + </Row> + )} + </Column> + <Row justifyContent="end" gap> + <Button onPress={onClose}>{formatMessage(labels.cancel)}</Button> + <Button variant="primary" onPress={handleSave} isDisabled={disabled}> + {formatMessage(labels.apply)} + </Button> + </Row> + </Column> + ); +} diff --git a/src/components/metrics/EventData.tsx b/src/components/metrics/EventData.tsx new file mode 100644 index 0000000..48d21c5 --- /dev/null +++ b/src/components/metrics/EventData.tsx @@ -0,0 +1,22 @@ +import { Column, Grid, Label, Text } from '@umami/react-zen'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useEventDataQuery } from '@/components/hooks'; + +export function EventData({ websiteId, eventId }: { websiteId: string; eventId: string }) { + const { data, isLoading, error } = useEventDataQuery(websiteId, eventId); + + return ( + <LoadingPanel isLoading={isLoading} error={error}> + <Grid columns="1fr 1fr" gap="5"> + {data?.map(({ dataKey, stringValue }) => { + return ( + <Column key={dataKey}> + <Label>{dataKey}</Label> + <Text>{stringValue}</Text> + </Column> + ); + })} + </Grid> + </LoadingPanel> + ); +} diff --git a/src/components/metrics/EventsChart.tsx b/src/components/metrics/EventsChart.tsx new file mode 100644 index 0000000..3a53ba9 --- /dev/null +++ b/src/components/metrics/EventsChart.tsx @@ -0,0 +1,93 @@ +import { colord } from 'colord'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { BarChart, type BarChartProps } from '@/components/charts/BarChart'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { + useDateRange, + useLocale, + useTimezone, + useWebsiteEventsSeriesQuery, +} from '@/components/hooks'; +import { renderDateLabels } from '@/lib/charts'; +import { CHART_COLORS } from '@/lib/constants'; +import { generateTimeSeries } from '@/lib/date'; + +export interface EventsChartProps extends BarChartProps { + websiteId: string; + focusLabel?: string; +} + +export function EventsChart({ websiteId, focusLabel }: EventsChartProps) { + const { timezone } = useTimezone(); + const { + dateRange: { startDate, endDate, unit }, + } = useDateRange({ timezone: timezone }); + const { locale, dateLocale } = useLocale(); + const { data, isLoading, error } = useWebsiteEventsSeriesQuery(websiteId); + const [label, setLabel] = useState<string>(focusLabel); + + const chartData: any = useMemo(() => { + if (!data) return; + + const map = (data as any[]).reduce((obj, { x, t, y }) => { + if (!obj[x]) { + obj[x] = []; + } + + obj[x].push({ x: t, y }); + + return obj; + }, {}); + + if (!map || Object.keys(map).length === 0) { + return { + datasets: [ + { + data: generateTimeSeries([], startDate, endDate, unit, dateLocale), + lineTension: 0, + borderWidth: 1, + }, + ], + }; + } else { + 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, + }; + }), + focusLabel, + }; + } + }, [data, startDate, endDate, unit, focusLabel]); + + useEffect(() => { + if (label !== focusLabel) { + setLabel(focusLabel); + } + }, [focusLabel]); + + const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); + + return ( + <LoadingPanel isLoading={isLoading} error={error} minHeight="400px"> + {chartData && ( + <BarChart + chartData={chartData} + minDate={startDate} + maxDate={endDate} + unit={unit} + stacked={true} + renderXLabel={renderXLabel} + height="400px" + /> + )} + </LoadingPanel> + ); +} diff --git a/src/components/metrics/Legend.tsx b/src/components/metrics/Legend.tsx new file mode 100644 index 0000000..34ddb5a --- /dev/null +++ b/src/components/metrics/Legend.tsx @@ -0,0 +1,39 @@ +import { Row, StatusLight, Text } from '@umami/react-zen'; +import type { LegendItem } from 'chart.js/auto'; +import { colord } from 'colord'; + +export function Legend({ + items = [], + onClick, +}: { + items: any[]; + onClick: (index: LegendItem) => void; +}) { + if (!items.find(({ text }) => text)) { + return null; + } + + return ( + <Row gap wrap="wrap" justifyContent="center"> + {items.map(item => { + const { text, fillStyle, hidden } = item; + const color = colord(fillStyle); + + return ( + <Row key={text} onClick={() => onClick(item)}> + <StatusLight color={color.alpha(color.alpha() + 0.2).toHex()}> + <Text + size="2" + color={hidden ? 'disabled' : undefined} + truncate={true} + style={{ maxWidth: '300px' }} + > + {text} + </Text> + </StatusLight> + </Row> + ); + })} + </Row> + ); +} diff --git a/src/components/metrics/ListTable.tsx b/src/components/metrics/ListTable.tsx new file mode 100644 index 0000000..f233bfe --- /dev/null +++ b/src/components/metrics/ListTable.tsx @@ -0,0 +1,152 @@ +import { config, useSpring } from '@react-spring/web'; +import { Column, Grid, Row, Text } from '@umami/react-zen'; +import type { ReactNode } from 'react'; +import { FixedSizeList } from 'react-window'; +import { AnimatedDiv } from '@/components/common/AnimatedDiv'; +import { Empty } from '@/components/common/Empty'; +import { useMessages, useMobile } from '@/components/hooks'; +import { formatLongCurrency, formatLongNumber } from '@/lib/format'; + +const ITEM_SIZE = 30; + +interface ListData { + label: string; + count: number; + percent: number; +} + +export interface ListTableProps { + data?: ListData[]; + title?: string; + metric?: string; + className?: string; + renderLabel?: (data: ListData, index: number) => ReactNode; + renderChange?: (data: ListData, index: number) => ReactNode; + animate?: boolean; + virtualize?: boolean; + showPercentage?: boolean; + itemCount?: number; + currency?: string; +} + +export function ListTable({ + data = [], + title, + metric, + renderLabel, + renderChange, + animate = true, + virtualize = false, + showPercentage = true, + itemCount = 10, + currency, +}: ListTableProps) { + const { formatMessage, labels } = useMessages(); + const { isPhone } = useMobile(); + + const getRow = (row: ListData, index: number) => { + const { label, count, percent } = row; + + return ( + <AnimatedRow + key={`${label}${index}`} + label={renderLabel ? renderLabel(row, index) : (label ?? formatMessage(labels.unknown))} + value={count} + percent={percent} + animate={animate && !virtualize} + showPercentage={showPercentage} + change={renderChange ? renderChange(row, index) : null} + currency={currency} + isPhone={isPhone} + /> + ); + }; + + const ListTableRow = ({ index, style }) => { + return <div style={style}>{getRow(data[index], index)}</div>; + }; + + return ( + <Column gap> + <Grid alignItems="center" justifyContent="space-between" paddingLeft="2" columns="1fr 100px"> + <Text weight="bold">{title}</Text> + <Text weight="bold" align="center"> + {metric} + </Text> + </Grid> + <Column gap="1"> + {data?.length === 0 && <Empty />} + {virtualize && data.length > 0 ? ( + <FixedSizeList + width="100%" + height={itemCount * ITEM_SIZE} + itemCount={data.length} + itemSize={ITEM_SIZE} + > + {ListTableRow} + </FixedSizeList> + ) : ( + data.map(getRow) + )} + </Column> + </Column> + ); +} + +const AnimatedRow = ({ + label, + value = 0, + percent, + change, + animate, + showPercentage = true, + currency, + isPhone, +}) => { + const props = useSpring({ + width: percent, + y: !Number.isNaN(value) ? value : 0, + from: { width: 0, y: 0 }, + config: animate ? config.default : { duration: 0 }, + }); + + return ( + <Grid + columns="1fr 50px 50px" + paddingLeft="2" + alignItems="center" + hoverBackgroundColor="2" + borderRadius + gap + > + <Row alignItems="center"> + <Text truncate={true} style={{ maxWidth: isPhone ? '200px' : '400px' }}> + {label} + </Text> + </Row> + <Row alignItems="center" height="30px" justifyContent="flex-end"> + {change} + <Text weight="bold"> + <AnimatedDiv title={props?.y as any}> + {currency + ? props.y?.to(n => formatLongCurrency(n, currency)) + : props.y?.to(formatLongNumber)} + </AnimatedDiv> + </Text> + </Row> + {showPercentage && ( + <Row + alignItems="center" + justifyContent="flex-start" + position="relative" + border="left" + borderColor="8" + color="muted" + paddingLeft="3" + > + <AnimatedDiv>{props.width.to(n => `${n?.toFixed?.(0)}%`)}</AnimatedDiv> + </Row> + )} + </Grid> + ); +}; diff --git a/src/components/metrics/MetricCard.tsx b/src/components/metrics/MetricCard.tsx new file mode 100644 index 0000000..d15bcf1 --- /dev/null +++ b/src/components/metrics/MetricCard.tsx @@ -0,0 +1,56 @@ +import { useSpring } from '@react-spring/web'; +import { Column, Text } from '@umami/react-zen'; +import { AnimatedDiv } from '@/components/common/AnimatedDiv'; +import { ChangeLabel } from '@/components/metrics/ChangeLabel'; +import { formatNumber } from '@/lib/format'; + +export interface MetricCardProps { + value: number; + previousValue?: number; + change?: number; + label?: string; + reverseColors?: boolean; + formatValue?: (n: any) => string; + showLabel?: boolean; + showChange?: boolean; +} + +export const MetricCard = ({ + value = 0, + change = 0, + label, + reverseColors = false, + formatValue = formatNumber, + showLabel = true, + showChange = false, +}: MetricCardProps) => { + const diff = value - change; + const pct = ((value - diff) / diff) * 100; + const props = useSpring({ x: Number(value) || 0, from: { x: 0 } }); + const changeProps = useSpring({ x: Number(pct) || 0, from: { x: 0 } }); + + return ( + <Column + justifyContent="center" + paddingX="6" + paddingY="4" + borderRadius="3" + backgroundColor + border + > + {showLabel && ( + <Text weight="bold" wrap="nowrap"> + {label} + </Text> + )} + <Text size="8" weight="bold" wrap="nowrap"> + <AnimatedDiv title={value?.toString()}>{props?.x?.to(x => formatValue(x))}</AnimatedDiv> + </Text> + {showChange && ( + <ChangeLabel value={change} title={formatValue(change)} reverseColors={reverseColors}> + <AnimatedDiv>{changeProps?.x?.to(x => `${Math.abs(~~x)}%`)}</AnimatedDiv> + </ChangeLabel> + )} + </Column> + ); +}; diff --git a/src/components/metrics/MetricLabel.tsx b/src/components/metrics/MetricLabel.tsx new file mode 100644 index 0000000..31c331f --- /dev/null +++ b/src/components/metrics/MetricLabel.tsx @@ -0,0 +1,142 @@ +import { Row } from '@umami/react-zen'; +import { Favicon } from '@/components/common/Favicon'; +import { FilterLink } from '@/components/common/FilterLink'; +import { TypeIcon } from '@/components/common/TypeIcon'; +import { + useCountryNames, + useFormat, + useLocale, + useMessages, + useRegionNames, +} from '@/components/hooks'; +import { GROUPED_DOMAINS } from '@/lib/constants'; + +export interface MetricLabelProps { + type: string; + data: any; + onClick?: () => void; +} + +export function MetricLabel({ type, data }: MetricLabelProps) { + const { formatMessage, labels } = useMessages(); + const { formatValue, formatCity } = useFormat(); + const { locale } = useLocale(); + const { countryNames } = useCountryNames(locale); + const { getRegionName } = useRegionNames(locale); + + const { label, country, domain } = data; + + switch (type) { + case 'browser': + case 'os': + return ( + <FilterLink + type={type} + value={label} + label={formatValue(label, type)} + icon={<TypeIcon type={type} value={label} />} + /> + ); + + case 'channel': + return formatMessage(labels[label]); + + case 'city': + return ( + <FilterLink + type="city" + value={label} + label={formatCity(label, country)} + icon={ + country && ( + <img + src={`${process.env.basePath || ''}/images/country/${ + country?.toLowerCase() || 'xx' + }.png`} + alt={country} + /> + ) + } + /> + ); + + case 'region': + return ( + <FilterLink + type="region" + value={label} + label={getRegionName(label, country)} + icon={<TypeIcon type="country" value={country} />} + /> + ); + + case 'country': + return ( + <FilterLink + type="country" + value={(countryNames[label] && label) || label} + label={formatValue(label, 'country')} + icon={<TypeIcon type="country" value={label} />} + /> + ); + + case 'path': + case 'entry': + case 'exit': + return ( + <FilterLink + type={type === 'entry' || type === 'exit' ? 'path' : type} + value={label} + label={!label && formatMessage(labels.none)} + externalUrl={ + domain ? `${domain?.startsWith('http') ? domain : `https://${domain}`}${label}` : null + } + /> + ); + + case 'device': + return ( + <FilterLink + type="device" + value={labels[label] && label} + label={formatValue(label, 'device')} + icon={<TypeIcon type="device" value={label} />} + /> + ); + + case 'referrer': + return ( + <FilterLink + type="referrer" + value={label} + externalUrl={`https://${label}`} + label={!label && formatMessage(labels.none)} + icon={<Favicon domain={label} />} + /> + ); + + case 'domain': + if (label === 'Other') { + return `(${formatMessage(labels.other)})`; + } else { + const name = GROUPED_DOMAINS.find(({ domain }) => domain === label)?.name; + + if (!name) { + return null; + } + + return ( + <Row alignItems="center" gap="3"> + <Favicon domain={label} /> + {name} + </Row> + ); + } + + case 'language': + return formatValue(label, 'language'); + + default: + return <FilterLink type={type} value={label} />; + } +} diff --git a/src/components/metrics/MetricsBar.tsx b/src/components/metrics/MetricsBar.tsx new file mode 100644 index 0000000..850c6bc --- /dev/null +++ b/src/components/metrics/MetricsBar.tsx @@ -0,0 +1,14 @@ +import { Grid, type GridProps } from '@umami/react-zen'; +import type { ReactNode } from 'react'; + +export interface MetricsBarProps extends GridProps { + children?: ReactNode; +} + +export function MetricsBar({ children, ...props }: MetricsBarProps) { + return ( + <Grid columns="repeat(auto-fit, minmax(160px, 1fr))" gap {...props}> + {children} + </Grid> + ); +} diff --git a/src/components/metrics/MetricsExpandedTable.tsx b/src/components/metrics/MetricsExpandedTable.tsx new file mode 100644 index 0000000..f24c952 --- /dev/null +++ b/src/components/metrics/MetricsExpandedTable.tsx @@ -0,0 +1,139 @@ +import { Button, Column, DataColumn, DataTable, Icon, Row, SearchField } from '@umami/react-zen'; +import { type ReactNode, useState } from 'react'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useWebsiteExpandedMetricsQuery } from '@/components/hooks'; +import { X } from '@/components/icons'; +import { DownloadButton } from '@/components/input/DownloadButton'; +import { MetricLabel } from '@/components/metrics/MetricLabel'; +import { SESSION_COLUMNS } from '@/lib/constants'; +import { formatShortTime } from '@/lib/format'; + +export interface MetricsExpandedTableProps { + websiteId: string; + type?: string; + title?: string; + dataFilter?: (data: any) => any; + onSearch?: (search: string) => void; + params?: { [key: string]: any }; + allowSearch?: boolean; + allowDownload?: boolean; + renderLabel?: (row: any, index: number) => ReactNode; + onClose?: () => void; + children?: ReactNode; +} + +export function MetricsExpandedTable({ + websiteId, + type, + title, + params, + allowSearch = true, + allowDownload = true, + onClose, + children, +}: MetricsExpandedTableProps) { + const [search, setSearch] = useState(''); + const { formatMessage, labels } = useMessages(); + const isType = ['browser', 'country', 'device', 'os'].includes(type); + const showBounceDuration = SESSION_COLUMNS.includes(type); + + const { data, isLoading, isFetching, error } = useWebsiteExpandedMetricsQuery(websiteId, { + type, + search: isType ? undefined : search, + ...params, + }); + + const items = data?.map(({ name, ...props }) => ({ label: name, ...props })); + + return ( + <> + <Row alignItems="center" paddingBottom="3"> + {allowSearch && <SearchField value={search} onSearch={setSearch} delay={300} />} + <Row justifyContent="flex-end" flexGrow={1} gap> + {children} + {allowDownload && <DownloadButton filename={type} data={data} />} + {onClose && ( + <Button onPress={onClose} variant="quiet"> + <Icon> + <X /> + </Icon> + </Button> + )} + </Row> + </Row> + <LoadingPanel + data={data} + isFetching={isFetching} + isLoading={isLoading} + error={error} + height="100%" + loadingIcon="spinner" + > + <Column overflow="auto" minHeight="0" height="100%" paddingRight="3"> + {items && ( + <DataTable data={items}> + <DataColumn id="label" label={title} width="minmax(200px, 2fr)" align="start"> + {row => ( + <Row overflow="hidden"> + <MetricLabel type={type} data={row} /> + </Row> + )} + </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="pageviews" + label={formatMessage(labels.views)} + align="end" + width="120px" + > + {row => row?.pageviews?.toLocaleString()} + </DataColumn> + {showBounceDuration && [ + <DataColumn + key="bounceRate" + 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 + key="visitDuration" + 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/components/metrics/MetricsTable.tsx b/src/components/metrics/MetricsTable.tsx new file mode 100644 index 0000000..e99bd21 --- /dev/null +++ b/src/components/metrics/MetricsTable.tsx @@ -0,0 +1,95 @@ +import { Grid, Icon, Row, Text } from '@umami/react-zen'; +import { useEffect, useMemo } from 'react'; +import { LinkButton } from '@/components/common/LinkButton'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useMessages, useNavigation, useWebsiteMetricsQuery } from '@/components/hooks'; +import { Maximize } from '@/components/icons'; +import { MetricLabel } from '@/components/metrics/MetricLabel'; +import { percentFilter } from '@/lib/filters'; +import { ListTable, type ListTableProps } from './ListTable'; + +export interface MetricsTableProps extends ListTableProps { + websiteId: string; + type: string; + dataFilter?: (data: any) => any; + limit?: number; + showMore?: boolean; + filterLink?: boolean; + params?: Record<string, any>; + onDataLoad?: (data: any) => void; +} + +export function MetricsTable({ + websiteId, + type, + dataFilter, + limit, + showMore = false, + filterLink = true, + params, + onDataLoad, + ...props +}: MetricsTableProps) { + const { updateParams } = useNavigation(); + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetching, error } = useWebsiteMetricsQuery(websiteId, { + type, + limit, + ...params, + }); + + const filteredData = useMemo(() => { + if (data) { + let items = data as any[]; + + if (dataFilter) { + if (Array.isArray(dataFilter)) { + items = dataFilter.reduce((arr, filter) => { + return filter(arr); + }, items); + } else { + items = dataFilter(items); + } + } + + items = percentFilter(items); + + return items.map(({ x, y, z, ...props }) => ({ label: x, count: y, percent: z, ...props })); + } + return []; + }, [data, dataFilter, limit, type]); + + useEffect(() => { + if (data) { + onDataLoad?.(data); + } + }, [data]); + + const renderLabel = (row: any) => { + return filterLink ? <MetricLabel type={type} data={row} /> : row.label; + }; + + return ( + <LoadingPanel + data={data} + isFetching={isFetching} + isLoading={isLoading} + error={error} + minHeight="400px" + > + <Grid> + {data && <ListTable {...props} data={filteredData} renderLabel={renderLabel} />} + {showMore && limit && ( + <Row justifyContent="center" alignItems="flex-end"> + <LinkButton href={updateParams({ view: type })} variant="quiet"> + <Icon size="sm"> + <Maximize /> + </Icon> + <Text>{formatMessage(labels.more)}</Text> + </LinkButton> + </Row> + )} + </Grid> + </LoadingPanel> + ); +} diff --git a/src/components/metrics/PageviewsChart.tsx b/src/components/metrics/PageviewsChart.tsx new file mode 100644 index 0000000..b83f8dc --- /dev/null +++ b/src/components/metrics/PageviewsChart.tsx @@ -0,0 +1,98 @@ +import { useTheme } from '@umami/react-zen'; +import { useCallback, useMemo } from 'react'; +import { BarChart, type BarChartProps } from '@/components/charts/BarChart'; +import { useLocale, useMessages } from '@/components/hooks'; +import { renderDateLabels } from '@/lib/charts'; +import { getThemeColors } from '@/lib/colors'; +import { generateTimeSeries } from '@/lib/date'; + +export interface PageviewsChartProps extends BarChartProps { + data: { + pageviews: any[]; + sessions: any[]; + compare?: { + pageviews: any[]; + sessions: any[]; + }; + }; + unit: string; +} + +export function PageviewsChart({ data, unit, minDate, maxDate, ...props }: PageviewsChartProps) { + const { formatMessage, labels } = useMessages(); + const { theme } = useTheme(); + const { locale, dateLocale } = useLocale(); + const { colors } = useMemo(() => getThemeColors(theme), [theme]); + + const chartData: any = useMemo(() => { + if (!data) return; + + return { + __id: Date.now(), + datasets: [ + { + type: 'bar', + label: formatMessage(labels.visitors), + data: generateTimeSeries(data.sessions, minDate, maxDate, unit, dateLocale), + borderWidth: 1, + barPercentage: 0.9, + categoryPercentage: 0.9, + ...colors.chart.visitors, + order: 3, + }, + { + type: 'bar', + label: formatMessage(labels.views), + data: generateTimeSeries(data.pageviews, minDate, maxDate, unit, dateLocale), + barPercentage: 0.9, + categoryPercentage: 0.9, + borderWidth: 1, + ...colors.chart.views, + order: 4, + }, + ...(data.compare + ? [ + { + type: 'line', + label: `${formatMessage(labels.views)} (${formatMessage(labels.previous)})`, + data: generateTimeSeries( + data.compare.pageviews, + minDate, + maxDate, + unit, + dateLocale, + ), + borderWidth: 2, + backgroundColor: '#8601B0', + borderColor: '#8601B0', + order: 1, + }, + { + type: 'line', + label: `${formatMessage(labels.visitors)} (${formatMessage(labels.previous)})`, + data: generateTimeSeries(data.compare.sessions, minDate, maxDate, unit, dateLocale), + borderWidth: 2, + backgroundColor: '#f15bb5', + borderColor: '#f15bb5', + order: 2, + }, + ] + : []), + ], + }; + }, [data, locale]); + + const renderXLabel = useCallback(renderDateLabels(unit, locale), [unit, locale]); + + return ( + <BarChart + {...props} + chartData={chartData} + unit={unit} + minDate={minDate} + maxDate={maxDate} + renderXLabel={renderXLabel} + height="400px" + /> + ); +} diff --git a/src/components/metrics/RealtimeChart.tsx b/src/components/metrics/RealtimeChart.tsx new file mode 100644 index 0000000..f42b96d --- /dev/null +++ b/src/components/metrics/RealtimeChart.tsx @@ -0,0 +1,59 @@ +import { isBefore, startOfMinute, subMinutes } from 'date-fns'; +import { useMemo, useRef } from 'react'; +import { useTimezone } from '@/components/hooks'; +import { DEFAULT_ANIMATION_DURATION, REALTIME_RANGE } from '@/lib/constants'; +import type { RealtimeData } from '@/lib/types'; +import { PageviewsChart } from './PageviewsChart'; + +export interface RealtimeChartProps { + data: RealtimeData; + unit: string; + className?: string; +} + +export function RealtimeChart({ data, unit, ...props }: RealtimeChartProps) { + const { formatSeriesTimezone, fromUtc, timezone } = useTimezone(); + const endDate = startOfMinute(new Date()); + const startDate = subMinutes(endDate, REALTIME_RANGE); + const prevEndDate = useRef(endDate); + const prevData = useRef<string | null>(null); + + const chartData = useMemo(() => { + if (!data) { + return { pageviews: [], sessions: [] }; + } + + return { + pageviews: formatSeriesTimezone(data.series.views, 'x', timezone), + sessions: formatSeriesTimezone(data.series.visitors, 'x', timezone), + }; + }, [data, startDate, endDate, unit]); + + const animationDuration = useMemo(() => { + // Don't animate the bars shifting over because it looks weird + if (isBefore(prevEndDate.current, endDate)) { + prevEndDate.current = endDate; + return 0; + } + + // Don't animate when data hasn't changed + const serialized = JSON.stringify(chartData); + if (prevData.current === serialized) { + return 0; + } + prevData.current = serialized; + + return DEFAULT_ANIMATION_DURATION; + }, [endDate, chartData]); + + return ( + <PageviewsChart + {...props} + minDate={fromUtc(startDate)} + maxDate={fromUtc(endDate)} + unit={unit} + data={chartData} + animationDuration={animationDuration} + /> + ); +} diff --git a/src/components/metrics/WeeklyTraffic.tsx b/src/components/metrics/WeeklyTraffic.tsx new file mode 100644 index 0000000..90e47c6 --- /dev/null +++ b/src/components/metrics/WeeklyTraffic.tsx @@ -0,0 +1,112 @@ +import { Focusable, Grid, Row, Text, Tooltip, TooltipTrigger } from '@umami/react-zen'; +import { addHours, format, startOfDay } from 'date-fns'; +import { LoadingPanel } from '@/components/common/LoadingPanel'; +import { useLocale, useMessages, useWeeklyTrafficQuery } from '@/components/hooks'; +import { getDayOfWeekAsDate } from '@/lib/date'; + +export function WeeklyTraffic({ websiteId }: { websiteId: string }) { + const { data, isLoading, error } = useWeeklyTrafficQuery(websiteId); + const { dateLocale } = useLocale(); + const { labels, formatMessage } = useMessages(); + const { weekStartsOn } = dateLocale.options; + const daysOfWeek = Array(7) + .fill(weekStartsOn) + .map((d, i) => (d + i) % 7); + + const [, max = 1] = data + ? data.reduce((arr: number[], hours: number[], index: number) => { + const min = Math.min(...hours); + const max = Math.max(...hours); + + if (index === 0) { + return [min, max]; + } + + if (min < arr[0]) { + arr[0] = min; + } + + if (max > arr[1]) { + arr[1] = max; + } + + return arr; + }, []) + : []; + + return ( + <LoadingPanel data={data} isLoading={isLoading} error={error}> + <Grid columns="repeat(8, 1fr)" gap> + {data && ( + <> + <Grid rows="repeat(25, 16px)" gap="1"> + <Row> </Row> + {Array(24) + .fill(null) + .map((_, i) => { + const label = format(addHours(startOfDay(new Date()), i), 'haaa', { + locale: dateLocale, + }); + return ( + <Row key={i} justifyContent="flex-end"> + <Text color="muted" size="2"> + {label} + </Text> + </Row> + ); + })} + </Grid> + {daysOfWeek.map((index: number) => { + const day = data[index]; + return ( + <Grid + rows="repeat(24, 16px)" + justifyContent="center" + alignItems="center" + key={index} + gap="1" + > + <Row alignItems="center" justifyContent="center" marginBottom="3"> + <Text weight="bold" align="center"> + {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })} + </Text> + </Row> + {day?.map((count: number, j) => { + const pct = max ? count / max : 0; + return ( + <TooltipTrigger key={j} delay={0} isDisabled={count <= 0}> + <Focusable> + <Row + alignItems="center" + justifyContent="center" + backgroundColor="2" + width="16px" + height="16px" + borderRadius="full" + style={{ margin: '0 auto' }} + role="button" + > + <Row + backgroundColor="primary" + width="16px" + height="16px" + borderRadius="full" + style={{ opacity: pct, transform: `scale(${pct})` }} + /> + </Row> + </Focusable> + <Tooltip placement="right">{`${formatMessage( + labels.visitors, + )}: ${count}`}</Tooltip> + </TooltipTrigger> + ); + })} + </Grid> + ); + })} + </> + )} + </Grid> + </LoadingPanel> + ); +} diff --git a/src/components/metrics/WorldMap.tsx b/src/components/metrics/WorldMap.tsx new file mode 100644 index 0000000..3c8fadb --- /dev/null +++ b/src/components/metrics/WorldMap.tsx @@ -0,0 +1,105 @@ +import { Column, type ColumnProps, FloatingTooltip, useTheme } from '@umami/react-zen'; +import { colord } from 'colord'; +import { useMemo, useState } from 'react'; +import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps'; +import { + useCountryNames, + useLocale, + useMessages, + useWebsiteMetricsQuery, +} from '@/components/hooks'; +import { getThemeColors } from '@/lib/colors'; +import { ISO_COUNTRIES, MAP_FILE } from '@/lib/constants'; +import { percentFilter } from '@/lib/filters'; +import { formatLongNumber } from '@/lib/format'; + +export interface WorldMapProps extends ColumnProps { + websiteId?: string; + data?: any[]; +} + +export function WorldMap({ websiteId, data, ...props }: WorldMapProps) { + const [tooltip, setTooltipPopup] = useState(); + const { theme } = useTheme(); + const { colors } = getThemeColors(theme); + const { locale } = useLocale(); + const { formatMessage, labels } = useMessages(); + const { countryNames } = useCountryNames(locale); + const visitorsLabel = formatMessage(labels.visitors).toLocaleLowerCase(locale); + const unknownLabel = formatMessage(labels.unknown); + + const { data: mapData } = useWebsiteMetricsQuery(websiteId, { + type: 'country', + }); + + const metrics = useMemo( + () => (data || mapData ? percentFilter((data || mapData) as any[]) : []), + [data, mapData], + ); + + const getFillColor = (code: string) => { + if (code === 'AQ') return; + const country = metrics?.find(({ x }) => x === code); + + if (!country) { + return colors.map.fillColor; + } + + return colord(colors.map.baseColor) + [theme === 'light' ? 'lighten' : 'darken'](0.4 * (1.0 - country.z / 100)) + .toHex(); + }; + + const getOpacity = (code: string) => { + return code === 'AQ' ? 0 : 1; + }; + + const handleHover = (code: string) => { + if (code === 'AQ') return; + const country = metrics?.find(({ x }) => x === code); + setTooltipPopup( + `${countryNames[code] || unknownLabel}: ${formatLongNumber( + country?.y || 0, + )} ${visitorsLabel}` as any, + ); + }; + + return ( + <Column + {...props} + data-tip="" + data-for="world-map-tooltip" + style={{ margin: 'auto 0', overflow: 'hidden' }} + > + <ComposableMap projection="geoMercator"> + <ZoomableGroup zoom={0.8} minZoom={0.7} center={[0, 40]}> + <Geographies geography={`${process.env.basePath || ''}${MAP_FILE}`}> + {({ geographies }) => { + return geographies.map(geo => { + const code = ISO_COUNTRIES[geo.id]; + + return ( + <Geography + key={geo.rsmKey} + geography={geo} + fill={getFillColor(code)} + stroke={colors.map.strokeColor} + opacity={getOpacity(code)} + style={{ + default: { outline: 'none' }, + hover: { outline: 'none', fill: colors.map.hoverColor }, + pressed: { outline: 'none' }, + }} + onMouseOver={() => handleHover(code)} + onMouseOut={() => setTooltipPopup(null)} + /> + ); + }); + }} + </Geographies> + </ZoomableGroup> + </ComposableMap> + {tooltip && <FloatingTooltip>{tooltip}</FloatingTooltip>} + </Column> + ); +} diff --git a/src/components/svg/AddUser.tsx b/src/components/svg/AddUser.tsx new file mode 100644 index 0000000..d1eb509 --- /dev/null +++ b/src/components/svg/AddUser.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +const SvgAddUser = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={512} + height={512} + data-name="Layer 2" + viewBox="0 0 30 30" + {...props} + > + <path d="M15 14a5.5 5.5 0 1 1 5.5-5.5A5.51 5.51 0 0 1 15 14m0-9a3.5 3.5 0 1 0 3.5 3.5A3.5 3.5 0 0 0 15 5M7.5 24.5a1 1 0 0 1-1-1 8.5 8.5 0 0 1 13.6-6.8 1 1 0 1 1-1.2 1.6A6.44 6.44 0 0 0 15 17a6.51 6.51 0 0 0-6.5 6.5 1 1 0 0 1-1 1M23 27a1 1 0 0 1-1-1v-6a1 1 0 0 1 2 0v6a1 1 0 0 1-1 1" /> + <path d="M26 24h-6a1 1 0 0 1 0-2h6a1 1 0 0 1 0 2" /> + </svg> +); +export default SvgAddUser; diff --git a/src/components/svg/BarChart.tsx b/src/components/svg/BarChart.tsx new file mode 100644 index 0000000..96ebe00 --- /dev/null +++ b/src/components/svg/BarChart.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgBarChart = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}> + <path d="M7 13v9a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1m7-12h-4a1 1 0 0 0-1 1v20a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1m8 5h-4a1 1 0 0 0-1 1v15a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1" /> + </svg> +); +export default SvgBarChart; diff --git a/src/components/svg/Bars.tsx b/src/components/svg/Bars.tsx new file mode 100644 index 0000000..1ce88f7 --- /dev/null +++ b/src/components/svg/Bars.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgBars = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}> + <path d="M424 392H24c-13.2 0-24 10.8-24 24s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24m0-320H24C10.8 72 0 82.8 0 96s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24m0 160H24c-13.2 0-24 10.8-24 24s10.8 24 24 24h400c13.2 0 24-10.8 24-24s-10.8-24-24-24" /> + </svg> +); +export default SvgBars; diff --git a/src/components/svg/Bolt.tsx b/src/components/svg/Bolt.tsx new file mode 100644 index 0000000..23b1e76 --- /dev/null +++ b/src/components/svg/Bolt.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgBolt = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" {...props}> + <path d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36" /> + </svg> +); +export default SvgBolt; diff --git a/src/components/svg/Bookmark.tsx b/src/components/svg/Bookmark.tsx new file mode 100644 index 0000000..089f61f --- /dev/null +++ b/src/components/svg/Bookmark.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgBookmark = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}> + <path d="M3.515 22.875a1 1 0 0 0 1.015-.027L12 18.179l7.47 4.669A1 1 0 0 0 21 22V4a3 3 0 0 0-3-3H6a3 3 0 0 0-3 3v18a1 1 0 0 0 .515.875M5 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v16.2l-6.47-4.044a1 1 0 0 0-1.06 0L5 20.2z" /> + </svg> +); +export default SvgBookmark; diff --git a/src/components/svg/Calendar.tsx b/src/components/svg/Calendar.tsx new file mode 100644 index 0000000..dfb848a --- /dev/null +++ b/src/components/svg/Calendar.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgCalendar = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" {...props}> + <path d="M400 64h-48V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H128V12c0-6.6-5.4-12-12-12h-8c-6.6 0-12 5.4-12 12v52H48C21.5 64 0 85.5 0 112v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48M48 96h352c8.8 0 16 7.2 16 16v48H32v-48c0-8.8 7.2-16 16-16m352 384H48c-8.8 0-16-7.2-16-16V192h384v272c0 8.8-7.2 16-16 16M148 320h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m-96 96h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m-96 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12m192 0h-40c-6.6 0-12-5.4-12-12v-40c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12" /> + </svg> +); +export default SvgCalendar; diff --git a/src/components/svg/Change.tsx b/src/components/svg/Change.tsx new file mode 100644 index 0000000..935a2f7 --- /dev/null +++ b/src/components/svg/Change.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react'; + +const SvgChange = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + xmlSpace="preserve" + viewBox="0 0 512.013 512.013" + {...props} + > + <path d="m372.653 244.726 22.56 22.56 112-112c6.204-6.241 6.204-16.319 0-22.56l-112-112-22.56 22.72 84.8 84.64H.013v32h457.44zm139.36 107.36H54.573l84.8-84.64-22.72-22.72-112 112c-6.204 6.241-6.204 16.319 0 22.56l112 112 22.56-22.56-84.64-84.64h457.44z" /> + </svg> +); +export default SvgChange; diff --git a/src/components/svg/Clock.tsx b/src/components/svg/Clock.tsx new file mode 100644 index 0000000..2dfa6a6 --- /dev/null +++ b/src/components/svg/Clock.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from 'react'; + +const SvgClock = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}> + <g clipRule="evenodd"> + <path d="M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12" /> + <path d="M11.168 11.445a1 1 0 0 1 1.387-.277l3 2a1 1 0 0 1-1.11 1.664l-3-2a1 1 0 0 1-.277-1.387" /> + <path d="M12 6a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V7a1 1 0 0 1 1-1" /> + </g> + </svg> +); +export default SvgClock; diff --git a/src/components/svg/Compare.tsx b/src/components/svg/Compare.tsx new file mode 100644 index 0000000..3434461 --- /dev/null +++ b/src/components/svg/Compare.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgCompare = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}> + <path d="M6 22a1 1 0 0 1-.71-.29l-4-4a1 1 0 0 1 0-1.42l4-4a1 1 0 0 1 1.42 1.42L4.41 16H22a1 1 0 0 1 0 2H4.41l2.3 2.29a1 1 0 0 1 0 1.42A1 1 0 0 1 6 22m12-10a1 1 0 0 1-.71-.29 1 1 0 0 1 0-1.42L19.59 8H2a1 1 0 0 1 0-2h17.59l-2.3-2.29a1 1 0 0 1 1.42-1.42l4 4a1 1 0 0 1 0 1.42l-4 4A1 1 0 0 1 18 12" /> + </svg> +); +export default SvgCompare; diff --git a/src/components/svg/Dashboard.tsx b/src/components/svg/Dashboard.tsx new file mode 100644 index 0000000..5696244 --- /dev/null +++ b/src/components/svg/Dashboard.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from 'react'; + +const SvgDashboard = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + className="dashboard_svg__lucide dashboard_svg__lucide-layout-dashboard" + viewBox="0 0 24 24" + {...props} + > + <rect width={7} height={9} x={3} y={3} rx={1} /> + <rect width={7} height={5} x={14} y={3} rx={1} /> + <rect width={7} height={9} x={14} y={12} rx={1} /> + <rect width={7} height={5} x={3} y={16} rx={1} /> + </svg> +); +export default SvgDashboard; diff --git a/src/components/svg/Download.tsx b/src/components/svg/Download.tsx new file mode 100644 index 0000000..5f58724 --- /dev/null +++ b/src/components/svg/Download.tsx @@ -0,0 +1,9 @@ +import type { SVGProps } from 'react'; + +const SvgDownload = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" {...props}> + <path d="M97.5 82.656V71.357a3.545 3.545 0 0 0-3.545-3.544H89.17a3.545 3.545 0 0 0-3.545 3.544v11.3c0 1.639-1.33 2.968-2.969 2.968H17.344a2.97 2.97 0 0 1-2.969-2.969V71.357a3.545 3.545 0 0 0-3.545-3.545H6.045A3.545 3.545 0 0 0 2.5 71.357v11.3C2.5 90.853 9.146 97.5 17.344 97.5h65.312c8.198 0 14.844-6.646 14.844-14.844" /> + <path d="m29.68 44.105-3.387 3.388a3.545 3.545 0 0 0 0 5.014l19.506 19.506a5.94 5.94 0 0 0 8.397.005l.005-.005 19.506-19.506a3.545 3.545 0 0 0 0-5.014l-3.388-3.388a3.545 3.545 0 0 0-5.013 0l-9.368 9.368V6.045A3.545 3.545 0 0 0 52.393 2.5h-4.786a3.545 3.545 0 0 0-3.544 3.545v47.428l-9.369-9.368a3.545 3.545 0 0 0-5.013 0" /> + </svg> +); +export default SvgDownload; diff --git a/src/components/svg/Expand.tsx b/src/components/svg/Expand.tsx new file mode 100644 index 0000000..a0f472e --- /dev/null +++ b/src/components/svg/Expand.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react'; + +const SvgExpand = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={512} + height={512} + fillRule="evenodd" + strokeLinejoin="round" + strokeMiterlimit={2} + clipRule="evenodd" + viewBox="0 0 48 48" + {...props} + > + <path d="M7.5 40.018v-10.5c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5v11a4.5 4.5 0 0 0 4.5 4.5h12a2.5 2.5 0 0 0 0-5zm33 0H29a2.5 2.5 0 0 0 0 5h12a4.5 4.5 0 0 0 4.5-4.5v-11c0-1.379-1.12-2.5-2.5-2.5s-2.5 1.121-2.5 2.5zm-33-33H19a2.5 2.5 0 0 0 0-5H7a4.5 4.5 0 0 0-4.5 4.5v11a2.5 2.5 0 0 0 5 0zm33 0v10.5a2.5 2.5 0 0 0 5 0v-11a4.5 4.5 0 0 0-4.5-4.5H29a2.5 2.5 0 0 0 0 5z" /> + </svg> +); +export default SvgExpand; diff --git a/src/components/svg/Export.tsx b/src/components/svg/Export.tsx new file mode 100644 index 0000000..5c1ef14 --- /dev/null +++ b/src/components/svg/Export.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from 'react'; + +const SvgExport = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}> + <switch> + <g> + <path d="M8.7 7.7 11 5.4V15c0 .6.4 1 1 1s1-.4 1-1V5.4l2.3 2.3c.4.4 1 .4 1.4 0s.4-1 0-1.4l-4-4c-.1-.1-.2-.2-.3-.2-.2-.1-.5-.1-.8 0-.1 0-.2.1-.3.2l-4 4c-.4.4-.4 1 0 1.4s1 .4 1.4 0M21 14c-.6 0-1 .4-1 1v4c0 .6-.4 1-1 1H5c-.6 0-1-.4-1-1v-4c0-.6-.4-1-1-1s-1 .4-1 1v4c0 1.7 1.3 3 3 3h14c1.7 0 3-1.3 3-3v-4c0-.6-.4-1-1-1" /> + </g> + </switch> + </svg> +); +export default SvgExport; diff --git a/src/components/svg/Flag.tsx b/src/components/svg/Flag.tsx new file mode 100644 index 0000000..34af943 --- /dev/null +++ b/src/components/svg/Flag.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgFlag = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 510 510" {...props}> + <path d="m393.159 121.41 69.152-86.44c-16.753-2.022-149.599-37.363-282.234-8.913V0h-30v361.898c-25.85 6.678-45 30.195-45 58.102v1.509c-34.191 6.969-60 37.272-60 73.491v15h240v-15c0-36.22-25.809-66.522-60-73.491V420c0-27.906-19.15-51.424-45-58.102V237.165c153.335-30.989 264.132 7.082 284.847 9.834zM252.506 480H77.647c6.19-17.461 22.873-30 42.43-30h90c19.556 0 36.238 12.539 42.429 30m-57.429-60h-60c0-16.542 13.458-30 30-30s30 13.458 30 30m-15-213.427V56.771c66.329-15.269 141.099-15.756 227.537-1.455l-50.619 63.274 48.8 85.4c-75.047-12.702-150.759-11.841-225.718 2.583" /> + </svg> +); +export default SvgFlag; diff --git a/src/components/svg/Funnel.tsx b/src/components/svg/Funnel.tsx new file mode 100644 index 0000000..63cf47d --- /dev/null +++ b/src/components/svg/Funnel.tsx @@ -0,0 +1,18 @@ +import type { SVGProps } from 'react'; + +const SvgFunnel = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={512} + height={512} + fill="currentColor" + viewBox="0 0 32 32" + {...props} + > + <path d="M29 11H3a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h26a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1M4 9h24V5H4z" /> + <path d="M25 17H7a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h18a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1M8 15h16v-4H8z" /> + <path d="M22 23H10a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1m-11-2h10v-4H11z" /> + <path d="M19 29h-6a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1m-5-2h4v-4h-4z" /> + </svg> +); +export default SvgFunnel; diff --git a/src/components/svg/Gear.tsx b/src/components/svg/Gear.tsx new file mode 100644 index 0000000..539b838 --- /dev/null +++ b/src/components/svg/Gear.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgGear = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}> + <path d="M504.265 315.978c0-8.652-4.607-16.844-12.359-21.392l-32.908-18.971a199 199 0 0 0 0-39.23l32.908-18.971c7.752-4.548 12.359-12.74 12.359-21.392 0-21.267-49.318-128.176-84.519-128.176-4.244 0-8.51 1.093-12.367 3.357l-32.78 18.969a195 195 0 0 0-34.068-19.744v-37.94c0-11.226-7.484-21.035-18.326-23.875C300.654 2.871 278.425 0 256.181 0a257.7 257.7 0 0 0-66.121 8.613c-10.842 2.84-18.326 12.649-18.326 23.875v37.94a195 195 0 0 0-34.068 19.744l-32.78-18.969a24.36 24.36 0 0 0-12.367-3.357h-.007C60.048 67.846 8 169.591 8 196.022c0 8.652 4.607 16.844 12.359 21.392l32.908 18.971a199 199 0 0 0 0 39.23l-32.908 18.971C12.607 299.134 8 307.326 8 315.978c0 21.267 49.318 128.176 84.519 128.176 4.244 0 8.51-1.093 12.367-3.357l32.78-18.969a195 195 0 0 0 34.068 19.744v37.94c0 11.226 7.484 21.035 18.326 23.875 21.551 5.742 43.78 8.613 66.024 8.613 22.246 0 44.506-2.871 66.121-8.613 10.842-2.84 18.326-12.649 18.326-23.875v-37.94a195 195 0 0 0 34.068-19.744l32.78 18.969a24.36 24.36 0 0 0 12.367 3.357c32.463 0 84.519-101.731 84.519-128.176m-88.904 73.981c-23.8-13.773-11.26-6.515-43.656-25.264-42.056 30.395-32.33 24.731-79.174 45.887v50.238a210 210 0 0 1-36.438 3.18 209 209 0 0 1-36.359-3.176v-50.242c-46.955-21.206-37.182-15.538-79.174-45.887l-43.636 25.254a207.4 207.4 0 0 1-36.407-63.109c21.126-12.177 11.844-6.826 43.571-25.117-2.539-25.64-3.811-35.644-3.811-45.683 0-10.022 1.268-20.08 3.811-45.763-31.89-18.385-22.517-12.982-43.584-25.125a207.1 207.1 0 0 1 36.4-63.111c23.8 13.773 11.26 6.515 43.656 25.264 42.056-30.395 32.33-24.731 79.174-45.887V51.18A210 210 0 0 1 256.172 48c15.425 0 27.954 1.694 36.359 3.176v50.242c46.955 21.206 37.182 15.538 79.174 45.887l43.638-25.254a207.4 207.4 0 0 1 36.405 63.109c-21.126 12.177-11.844 6.826-43.571 25.117 2.539 25.64 3.811 35.644 3.811 45.683 0 10.022-1.268 20.08-3.811 45.763 31.89 18.385 22.517 12.982 43.584 25.125a207.1 207.1 0 0 1-36.4 63.111M256.133 160c-52.875 0-96 43.125-96 96s43.125 96 96 96 96-43.125 96-96-43.125-96-96-96m0 144c-26.467 0-48-21.533-48-48s21.533-48 48-48 48 21.533 48 48-21.534 48-48 48" /> + </svg> +); +export default SvgGear; diff --git a/src/components/svg/Globe.tsx b/src/components/svg/Globe.tsx new file mode 100644 index 0000000..385017d --- /dev/null +++ b/src/components/svg/Globe.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgGlobe = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" {...props}> + <path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8m179.3 160h-67.2c-6.7-36.5-17.5-68.8-31.2-94.7 42.9 19 77.7 52.7 98.4 94.7M248 56c18.6 0 48.6 41.2 63.2 112H184.8C199.4 97.2 229.4 56 248 56M48 256c0-13.7 1.4-27.1 4-40h77.7c-1 13.1-1.7 26.3-1.7 40s.7 26.9 1.7 40H52c-2.6-12.9-4-26.3-4-40m20.7 88h67.2c6.7 36.5 17.5 68.8 31.2 94.7-42.9-19-77.7-52.7-98.4-94.7m67.2-176H68.7c20.7-42 55.5-75.7 98.4-94.7-13.7 25.9-24.5 58.2-31.2 94.7M248 456c-18.6 0-48.6-41.2-63.2-112h126.5c-14.7 70.8-44.7 112-63.3 112m70.1-160H177.9c-1.1-12.8-1.9-26-1.9-40s.8-27.2 1.9-40h140.3c1.1 12.8 1.9 26 1.9 40s-.9 27.2-2 40m10.8 142.7c13.7-25.9 24.4-58.2 31.2-94.7h67.2c-20.7 42-55.5 75.7-98.4 94.7M366.3 296c1-13.1 1.7-26.3 1.7-40s-.7-26.9-1.7-40H444c2.6 12.9 4 26.3 4 40s-1.4 27.1-4 40z" /> + </svg> +); +export default SvgGlobe; diff --git a/src/components/svg/Lightbulb.tsx b/src/components/svg/Lightbulb.tsx new file mode 100644 index 0000000..8d86170 --- /dev/null +++ b/src/components/svg/Lightbulb.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgLightbulb = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + xmlSpace="preserve" + fill="currentColor" + viewBox="0 0 512 512" + {...props} + > + <path d="M223.718 124.76c-48.027 11.198-86.688 49.285-98.494 97.031-11.843 47.899 1.711 96.722 36.259 130.601C173.703 364.377 181 383.586 181 403.777V407c0 13.296 5.801 25.26 15 33.505V467c0 24.813 20.187 45 45 45h30c24.813 0 45-20.187 45-45v-26.495c9.199-8.245 15-20.208 15-33.505v-3.282c0-19.884 7.687-39.458 20.563-52.361C376.994 325.87 391 292.005 391 256c0-86.079-79.769-151.638-167.282-131.24M286 467c0 8.271-6.729 15-15 15h-30c-8.271 0-15-6.729-15-15v-15h60zm44.326-136.834C311.689 348.843 301 375.651 301 403.718V407c0 8.271-6.729 15-15 15h-60c-8.271 0-15-6.729-15-15v-3.223c0-28.499-10.393-55.035-28.513-72.804-26.89-26.37-37.409-64.493-28.141-101.981 9.125-36.907 39.029-66.353 76.184-75.015C299.202 137.964 361 189.228 361 256c0 28.004-10.894 54.343-30.674 74.166M139.327 118.114 96.9 75.688c-5.857-5.858-15.355-5.858-21.213 0s-5.858 15.355 0 21.213l42.427 42.426c5.857 5.858 15.356 5.858 21.213 0s5.858-15.355 0-21.213M76 241H15c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15m421 0h-61c-8.284 0-15 6.716-15 15s6.716 15 15 15h61c8.284 0 15-6.716 15-15s-6.716-15-15-15M436.313 75.688c-5.856-5.858-15.354-5.858-21.213 0l-42.427 42.426c-5.858 5.857-5.858 15.355 0 21.213s15.355 5.858 21.213 0l42.427-42.426c5.858-5.857 5.858-15.355 0-21.213M256 0c-8.284 0-15 6.716-15 15v61c0 8.284 6.716 15 15 15s15-6.716 15-15V15c0-8.284-6.716-15-15-15" /> + <path d="M256 181c-6.166 0-12.447.739-18.658 2.194-25.865 6.037-47.518 27.328-53.879 52.979-1.994 8.041 2.907 16.175 10.947 18.17 8.042 1.994 16.176-2.909 18.17-10.948 3.661-14.758 16.647-27.5 31.593-30.989 3.982-.933 7.962-1.406 11.827-1.406 8.284 0 15-6.716 15-15s-6.716-15-15-15" /> + </svg> +); +export default SvgLightbulb; diff --git a/src/components/svg/Lightning.tsx b/src/components/svg/Lightning.tsx new file mode 100644 index 0000000..9539a96 --- /dev/null +++ b/src/components/svg/Lightning.tsx @@ -0,0 +1,33 @@ +import type { SVGProps } from 'react'; + +const SvgLightning = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + xmlSpace="preserve" + viewBox="0 0 682.667 682.667" + {...props} + > + <defs> + <clipPath id="lightning_svg__a" clipPathUnits="userSpaceOnUse"> + <path d="M0 512h512V0H0Z" /> + </clipPath> + </defs> + <g clipPath="url(#lightning_svg__a)" transform="matrix(1.33333 0 0 -1.33333 0 682.667)"> + <path + d="M0 0h137.962L69.319-155.807h140.419L.242-482l55.349 222.794h-155.853z" + style={{ + fill: 'none', + stroke: 'currentColor', + strokeWidth: 30, + strokeLinecap: 'round', + strokeLinejoin: 'round', + strokeMiterlimit: 10, + strokeDasharray: 'none', + strokeOpacity: 1, + }} + transform="translate(201.262 496.994)" + /> + </g> + </svg> +); +export default SvgLightning; diff --git a/src/components/svg/Link.tsx b/src/components/svg/Link.tsx new file mode 100644 index 0000000..4ce88e7 --- /dev/null +++ b/src/components/svg/Link.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgLink = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}> + <path d="M314.222 197.78c51.091 51.091 54.377 132.287 9.75 187.16-6.242 7.73-2.784 3.865-84.94 86.02-54.696 54.696-143.266 54.745-197.99 0-54.711-54.69-54.734-143.255 0-197.99 32.773-32.773 51.835-51.899 63.409-63.457 7.463-7.452 20.331-2.354 20.486 8.192a173.3 173.3 0 0 0 4.746 37.828c.966 4.029-.272 8.269-3.202 11.198L80.632 312.57c-32.755 32.775-32.887 85.892 0 118.8 32.775 32.755 85.892 32.887 118.8 0l75.19-75.2c32.718-32.725 32.777-86.013 0-118.79a83.7 83.7 0 0 0-22.814-16.229c-4.623-2.233-7.182-7.25-6.561-12.346 1.356-11.122 6.296-21.885 14.815-30.405l4.375-4.375c3.625-3.626 9.177-4.594 13.76-2.294 12.999 6.524 25.187 15.211 36.025 26.049M470.958 41.04c-54.724-54.745-143.294-54.696-197.99 0-82.156 82.156-78.698 78.29-84.94 86.02-44.627 54.873-41.341 136.069 9.75 187.16 10.838 10.838 23.026 19.525 36.025 26.049 4.582 2.3 10.134 1.331 13.76-2.294l4.375-4.375c8.52-8.519 13.459-19.283 14.815-30.405.621-5.096-1.938-10.113-6.561-12.346a83.7 83.7 0 0 1-22.814-16.229c-32.777-32.777-32.718-86.065 0-118.79l75.19-75.2c32.908-32.887 86.025-32.755 118.8 0 32.887 32.908 32.755 86.025 0 118.8l-45.848 45.84c-2.93 2.929-4.168 7.169-3.202 11.198a173.3 173.3 0 0 1 4.746 37.828c.155 10.546 13.023 15.644 20.486 8.192 11.574-11.558 30.636-30.684 63.409-63.457 54.733-54.735 54.71-143.3-.001-197.991" /> + </svg> +); +export default SvgLink; diff --git a/src/components/svg/Location.tsx b/src/components/svg/Location.tsx new file mode 100644 index 0000000..0fd7d16 --- /dev/null +++ b/src/components/svg/Location.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgLocation = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 64 64" {...props}> + <path d="M32 0A24.03 24.03 0 0 0 8 24c0 17.23 22.36 38.81 23.31 39.72a.99.99 0 0 0 1.38 0C33.64 62.81 56 41.23 56 24A24.03 24.03 0 0 0 32 0m0 35a11 11 0 1 1 11-11 11.007 11.007 0 0 1-11 11" /> + </svg> +); +export default SvgLocation; diff --git a/src/components/svg/Lock.tsx b/src/components/svg/Lock.tsx new file mode 100644 index 0000000..2b62eb9 --- /dev/null +++ b/src/components/svg/Lock.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgLock = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}> + <path d="M18.75 9H18V6c0-3.309-2.691-6-6-6S6 2.691 6 6v3h-.75A2.253 2.253 0 0 0 3 11.25v10.5C3 22.991 4.01 24 5.25 24h13.5c1.24 0 2.25-1.009 2.25-2.25v-10.5C21 10.009 19.99 9 18.75 9M8 6c0-2.206 1.794-4 4-4s4 1.794 4 4v3H8zm5 10.722V19a1 1 0 1 1-2 0v-2.278c-.595-.347-1-.985-1-1.722 0-1.103.897-2 2-2s2 .897 2 2c0 .737-.405 1.375-1 1.722" /> + </svg> +); +export default SvgLock; diff --git a/src/components/svg/Logo.tsx b/src/components/svg/Logo.tsx new file mode 100644 index 0000000..eb9fdf5 --- /dev/null +++ b/src/components/svg/Logo.tsx @@ -0,0 +1,17 @@ +import type { SVGProps } from 'react'; + +const SvgLogo = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={20} + height={20} + fill="currentColor" + stroke="currentColor" + viewBox="0 0 428 389.11" + {...props} + > + <circle cx={214.15} cy={181} r={171} fill="none" strokeMiterlimit={10} strokeWidth={20} /> + <path d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15Z" /> + </svg> +); +export default SvgLogo; diff --git a/src/components/svg/LogoWhite.tsx b/src/components/svg/LogoWhite.tsx new file mode 100644 index 0000000..fb8c5f9 --- /dev/null +++ b/src/components/svg/LogoWhite.tsx @@ -0,0 +1,26 @@ +import type { SVGProps } from 'react'; + +const SvgLogoWhite = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={20} + height={20} + viewBox="0 0 428 389.11" + {...props} + > + <circle + cx={214.15} + cy={181} + r={171} + fill="none" + stroke="#fff" + strokeMiterlimit={10} + strokeWidth={20} + /> + <path + fill="#fff" + d="M413 134.11H15.29a15 15 0 0 0-15 15v15.3C.12 168 0 171.52 0 175.11c0 118.19 95.81 214 214 214 116.4 0 211.1-92.94 213.93-208.67 0-.44.07-.88.07-1.33v-30a15 15 0 0 0-15-15" + /> + </svg> +); +export default SvgLogoWhite; diff --git a/src/components/svg/Magnet.tsx b/src/components/svg/Magnet.tsx new file mode 100644 index 0000000..88b0f03 --- /dev/null +++ b/src/components/svg/Magnet.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgMagnet = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={512} + height={512} + fill="currentColor" + viewBox="0 0 508.467 508.467" + {...props} + > + <path d="M426.815 239.006c-11.722-11.724-30.702-11.729-42.427-.001L267.67 355.723c-53.811 53.809-142.478 19.197-140.68-54.511.547-22.415 9.826-43.738 26.129-60.041l116.717-116.717c11.724-11.722 11.728-30.702 0-42.427l-46.668-46.669c-11.725-11.725-30.702-11.726-42.427 0L60.629 155.47C21.579 194.52.047 246.44 0 301.665c-.093 110.827 88.182 206.288 206.244 206.394 56.778 0 109.204-21.924 148.29-61.01l118.948-118.948c11.724-11.722 11.728-30.702 0-42.427zM201.954 56.572l46.669 46.669-58.455 58.456-46.669-46.669zm131.367 369.264c-69.043 69.043-182.868 70.02-251.708.933-68.763-69.009-68.66-181.196.229-250.086l40.443-40.443 46.669 46.669-37.049 37.049c-45.115 45.112-46.916 116.85-3.395 160.371 43.279 43.279 115.221 41.756 160.372-3.394l37.049-37.049 46.669 46.669zm60.494-60.493-46.669-46.669 58.456-58.456 46.669 46.669zM379.357 95.099c15.199 3.839 30.418 19.07 34.336 34.192 2.089 8.058 10.303 12.828 18.283 10.758 8.02-2.078 12.836-10.264 10.758-18.283-6.651-25.662-30.176-49.223-56.03-55.753-8.032-2.027-16.188 2.838-18.217 10.869-2.029 8.032 2.837 16.189 10.87 18.217m128.627 7.025C495.968 55.749 452.769 12.62 406.239.868c-8.032-2.027-16.188 2.838-18.217 10.869-2.029 8.032 2.838 16.188 10.87 18.217 35.882 9.063 70.769 43.871 80.051 79.695 2.088 8.058 10.304 12.828 18.283 10.758 8.02-2.078 12.836-10.263 10.758-18.283" /> + </svg> +); +export default SvgMagnet; diff --git a/src/components/svg/Money.tsx b/src/components/svg/Money.tsx new file mode 100644 index 0000000..7d7b1e5 --- /dev/null +++ b/src/components/svg/Money.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgMoney = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + xmlSpace="preserve" + fill="currentColor" + viewBox="0 0 512 512" + {...props} + > + <path d="M347 302c8.271 0 15 6.639 15 14.8h30c0-19.468-12.541-36.067-30-42.231V242h-30v32.58c-17.459 6.192-30 22.865-30 42.42 0 24.813 20.187 45 45 45 8.271 0 15 6.729 15 15s-6.729 15-15 15-15-6.729-15-15h-30c0 19.555 12.541 36.228 30 42.42v32.38h30v-32.38c17.459-6.192 30-22.865 30-42.42 0-24.813-20.187-45-45-45-8.271 0-15-6.729-15-15s6.729-15 15-15" /> + <path d="M347 182c-5.057 0-10.058.242-15 .689V90c0-26.011-18.548-49.61-52.226-66.449C249.4 8.364 209.35 0 167 0 124.564 0 84.193 8.347 53.323 23.502 18.938 40.385 0 64 0 90v272c0 26 18.938 49.616 53.323 66.498C84.193 443.653 124.564 452 167 452c17.009 0 33.647-1.358 49.615-4.004C246.826 486.909 294.035 512 347 512c90.981 0 165-74.019 165-165s-74.019-165-165-165M66.545 50.432C92.992 37.447 129.606 30 167 30c79.558 0 135 31.621 135 60s-55.442 60-135 60c-37.394 0-74.008-7.447-100.455-20.432C43.32 118.166 30 103.744 30 90s13.32-28.166 36.545-39.568M30 142.265c6.724 5.137 14.512 9.907 23.323 14.233C84.193 171.653 124.564 180 167 180c42.35 0 82.4-8.364 112.774-23.551 8.359-4.18 15.783-8.776 22.226-13.722v45.51c-29.896 8.485-56.359 25.209-76.778 47.548C206.946 239.908 187.386 242 167 242c-37.394 0-74.008-7.447-100.455-20.432C43.32 210.166 30 195.744 30 182zm0 92c6.724 5.137 14.512 9.907 23.323 14.233C84.193 263.653 124.564 272 167 272c11.581 0 22.942-.621 34.021-1.839a163.7 163.7 0 0 0-18.293 61.395c-5.211.286-10.465.444-15.728.444-37.394 0-74.008-7.447-100.455-20.432C43.32 300.166 30 285.744 30 272zM167 422c-37.394 0-74.008-7.447-100.455-20.432C43.32 390.166 30 375.744 30 362v-37.736c6.724 5.137 14.512 9.907 23.323 14.233C84.193 353.653 124.564 362 167 362c5.23 0 10.459-.132 15.654-.388a163.7 163.7 0 0 0 16.486 58.557A281 281 0 0 1 167 422m180 60c-74.439 0-135-60.561-135-135s60.561-135 135-135 135 60.561 135 135-60.561 135-135 135" /> + </svg> +); +export default SvgMoney; diff --git a/src/components/svg/Moon.tsx b/src/components/svg/Moon.tsx new file mode 100644 index 0000000..40e3e8b --- /dev/null +++ b/src/components/svg/Moon.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgMoon = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1399.98 1400" {...props}> + <path d="M562.44 837.55C335.89 611 288.08 273.54 418.71 0a734.3 734.3 0 0 0-203.17 143.73c-287.39 287.39-287.39 753.33 0 1040.72s753.33 287.4 1040.74 0A733.8 733.8 0 0 0 1400 981.29c-273.55 130.63-611 82.8-837.56-143.74" /> + </svg> +); +export default SvgMoon; diff --git a/src/components/svg/Network.tsx b/src/components/svg/Network.tsx new file mode 100644 index 0000000..15941a9 --- /dev/null +++ b/src/components/svg/Network.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgNetwork = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={512} + height={512} + fill="currentColor" + viewBox="0 0 32 32" + {...props} + > + <path d="M28 19c-.809 0-1.54.325-2.08.847l-6.011-3.01c.058-.271.091-.55.091-.837s-.033-.566-.091-.837l6.011-3.01c.54.522 1.271.847 2.08.847 1.654 0 3-1.346 3-3s-1.346-3-3-3-3 1.346-3 3c0 .123.022.24.036.359L19 13.382a3.98 3.98 0 0 0-2-1.24V6.816A3 3 0 0 0 19 4c0-1.654-1.346-3-3-3s-3 1.346-3 3c0 1.302.838 2.401 2 2.815v5.327a4 4 0 0 0-2 1.24L6.963 10.36c.015-.12.037-.237.037-.36 0-1.654-1.346-3-3-3s-3 1.346-3 3 1.346 3 3 3c.809 0 1.54-.325 2.08-.847l6.011 3.01q-.089.407-.091.837c-.002.43.033.566.091.837l-6.011 3.01A2.98 2.98 0 0 0 4 19c-1.654 0-3 1.346-3 3s1.346 3 3 3 3-1.346 3-3c0-.123-.022-.24-.036-.359L13 18.618a3.98 3.98 0 0 0 2 1.24v5.326A3 3 0 0 0 13 28c0 1.654 1.346 3 3 3s3-1.346 3-3a3 3 0 0 0-2-2.816v-5.326a4 4 0 0 0 2-1.24l6.037 3.022c-.015.12-.037.237-.037.36 0 1.654 1.346 3 3 3s3-1.346 3-3-1.346-3-3-3m0-10c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1M4 11c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1m0 12c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1M16 3c.551 0 1 .449 1 1s-.449 1-1 1-1-.449-1-1 .449-1 1-1m0 26c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1m0-11c-1.103 0-2-.897-2-2s.897-2 2-2 2 .897 2 2-.897 2-2 2m12 5c-.551 0-1-.449-1-1s.449-1 1-1 1 .449 1 1-.449 1-1 1" /> + </svg> +); +export default SvgNetwork; diff --git a/src/components/svg/Nodes.tsx b/src/components/svg/Nodes.tsx new file mode 100644 index 0000000..1adfcb8 --- /dev/null +++ b/src/components/svg/Nodes.tsx @@ -0,0 +1,12 @@ +import type { SVGProps } from 'react'; + +const SvgNodes = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" width={512} height={512} viewBox="0 0 24 24" {...props}> + <path + fillRule="evenodd" + d="M19 9.874A4.002 4.002 0 0 0 18 2a4 4 0 0 0-3.874 3H9.874A4.002 4.002 0 0 0 2 6a4 4 0 0 0 3 3.874v4.252A4.002 4.002 0 0 0 6 22a4 4 0 0 0 3.874-3h4.252A4.002 4.002 0 0 0 22 18a4 4 0 0 0-3-3.874zM6 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4m3.874 3A4.01 4.01 0 0 1 7 9.874v4.252A4.01 4.01 0 0 1 9.874 17h4.252A4.01 4.01 0 0 1 17 14.126V9.874A4.01 4.01 0 0 1 14.126 7zM18 8a2 2 0 1 1 0-4 2 2 0 0 1 0 4m0 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4M8 18a2 2 0 1 0-4 0 2 2 0 0 0 4 0" + clipRule="evenodd" + /> + </svg> +); +export default SvgNodes; diff --git a/src/components/svg/Overview.tsx b/src/components/svg/Overview.tsx new file mode 100644 index 0000000..67e6af1 --- /dev/null +++ b/src/components/svg/Overview.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgOverview = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" xmlSpace="preserve" viewBox="0 0 512 512" {...props}> + <path d="M452 36H60C26.916 36 0 62.916 0 96v240c0 33.084 26.916 60 60 60h176v40H132v40h248v-40H276v-40h176c33.084 0 60-26.916 60-60V96c0-33.084-26.916-60-60-60m20 300c0 11.028-8.972 20-20 20H60c-11.028 0-20-8.972-20-20V96c0-11.028 8.972-20 20-20h392c11.028 0 20 8.972 20 20z" /> + </svg> +); +export default SvgOverview; diff --git a/src/components/svg/Path.tsx b/src/components/svg/Path.tsx new file mode 100644 index 0000000..7538ba4 --- /dev/null +++ b/src/components/svg/Path.tsx @@ -0,0 +1,15 @@ +import type { SVGProps } from 'react'; + +const SvgPath = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={512} + height={512} + fill="currentColor" + viewBox="0 0 64 64" + {...props} + > + <path d="m56.4 47.6-6-6c-.8-.8-2-.8-2.8 0s-.8 2 0 2.8l2.6 2.6H18.5c-3.6 0-6.5-2.9-6.5-6.5s2.9-6.5 6.5-6.5h27C51.3 34 56 29.3 56 23.5S51.3 13 45.5 13H22.7c-.9-3.4-4-6-7.7-6-4.4 0-8 3.6-8 8s3.6 8 8 8c3.7 0 6.8-2.6 7.7-6h22.8c3.6 0 6.5 2.9 6.5 6.5S49.1 30 45.5 30h-27C12.7 30 8 34.7 8 40.5S12.7 51 18.5 51h31.7l-2.6 2.6c-.8.8-.8 2 0 2.8.4.4.9.6 1.4.6s1-.2 1.4-.6l6-6c.8-.8.8-2 0-2.8M15 19c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4" /> + </svg> +); +export default SvgPath; diff --git a/src/components/svg/Profile.tsx b/src/components/svg/Profile.tsx new file mode 100644 index 0000000..c955fce --- /dev/null +++ b/src/components/svg/Profile.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgProfile = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}> + <path d="M437.02 74.98C388.668 26.63 324.379 0 256 0S123.332 26.629 74.98 74.98C26.63 123.332 0 187.621 0 256s26.629 132.668 74.98 181.02C123.332 485.37 187.621 512 256 512s132.668-26.629 181.02-74.98C485.37 388.668 512 324.379 512 256s-26.629-132.668-74.98-181.02M111.105 429.297c8.454-72.735 70.989-128.89 144.895-128.89 38.96 0 75.598 15.179 103.156 42.734 23.281 23.285 37.965 53.687 41.742 86.152C361.641 462.172 311.094 482 256 482s-105.637-19.824-144.895-52.703M256 269.507c-42.871 0-77.754-34.882-77.754-77.753C178.246 148.879 213.13 114 256 114s77.754 34.879 77.754 77.754c0 42.871-34.883 77.754-77.754 77.754zm170.719 134.427a175.9 175.9 0 0 0-46.352-82.004c-18.437-18.438-40.25-32.27-64.039-40.938 28.598-19.394 47.426-52.16 47.426-89.238C363.754 132.34 315.414 84 256 84s-107.754 48.34-107.754 107.754c0 37.098 18.844 69.875 47.465 89.266-21.887 7.976-42.14 20.308-59.566 36.542-25.235 23.5-42.758 53.465-50.883 86.348C50.852 364.242 30 312.512 30 256 30 131.383 131.383 30 256 30s226 101.383 226 226c0 56.523-20.86 108.266-55.281 147.934m0 0" /> + </svg> +); +export default SvgProfile; diff --git a/src/components/svg/Pushpin.tsx b/src/components/svg/Pushpin.tsx new file mode 100644 index 0000000..d19e98e --- /dev/null +++ b/src/components/svg/Pushpin.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgPushpin = (props: SVGProps<SVGSVGElement>) => ( + <svg width="1em" height="1em" fill="currentColor" viewBox="0 0 1024 1024" {...props}> + <path d="M878.3 392.1 631.9 145.7c-6.5-6.5-15-9.7-23.5-9.7s-17 3.2-23.5 9.7L423.8 306.9c-12.2-1.4-24.5-2-36.8-2-73.2 0-146.4 24.1-206.5 72.3-15.4 12.3-16.6 35.4-2.7 49.4l181.7 181.7-215.4 215.2a15.8 15.8 0 0 0-4.6 9.8l-3.4 37.2c-.9 9.4 6.6 17.4 15.9 17.4.5 0 1 0 1.5-.1l37.2-3.4c3.7-.3 7.2-2 9.8-4.6l215.4-215.4 181.7 181.7c6.5 6.5 15 9.7 23.5 9.7 9.7 0 19.3-4.2 25.9-12.4 56.3-70.3 79.7-158.3 70.2-243.4l161.1-161.1c12.9-12.8 12.9-33.8 0-46.8" /> + </svg> +); +export default SvgPushpin; diff --git a/src/components/svg/Redo.tsx b/src/components/svg/Redo.tsx new file mode 100644 index 0000000..04c389f --- /dev/null +++ b/src/components/svg/Redo.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgRedo = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}> + <path d="M500 8h-27.711c-6.739 0-12.157 5.548-11.997 12.286l2.347 98.568C418.075 51.834 341.788 7.73 255.207 8.001 118.82 8.428 7.787 120.009 8 256.396 8.214 393.181 119.165 504 256 504c63.926 0 122.202-24.187 166.178-63.908 5.113-4.618 5.354-12.561.482-17.433l-19.738-19.738c-4.498-4.498-11.753-4.785-16.501-.552C351.787 433.246 306.105 452 256 452c-108.322 0-196-87.662-196-196 0-108.322 87.662-196 196-196 79.545 0 147.941 47.282 178.675 115.302l-126.389-3.009c-6.737-.16-12.286 5.257-12.286 11.997V212c0 6.627 5.373 12 12 12h192c6.627 0 12-5.373 12-12V20c0-6.627-5.373-12-12-12" /> + </svg> +); +export default SvgRedo; diff --git a/src/components/svg/Reports.tsx b/src/components/svg/Reports.tsx new file mode 100644 index 0000000..b548966 --- /dev/null +++ b/src/components/svg/Reports.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgReports = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" {...props}> + <path d="M61.17 18.91A32 32 0 1 0 46.4 60.54l.15-.06.16-.1a31.93 31.93 0 0 0 14.47-41.44s-.01-.02-.01-.03m-4.53-.16L34 28.91V4.1a28 28 0 0 1 22.64 14.65M4 32A28 28 0 0 1 30 4.1V32a1.7 1.7 0 0 0 0 .39.2.2 0 0 0 0 .07 1.5 1.5 0 0 0 .15.4l12.76 24.9A28 28 0 0 1 4 32m42.47 23.94L34.74 33l23.54-10.6a28 28 0 0 1-11.81 33.54" /> + </svg> +); +export default SvgReports; diff --git a/src/components/svg/Security.tsx b/src/components/svg/Security.tsx new file mode 100644 index 0000000..d075a93 --- /dev/null +++ b/src/components/svg/Security.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +const SvgSecurity = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={512} + height={512} + data-name="Layer 1" + viewBox="0 0 36 36" + {...props} + > + <path d="M18 34a1.1 1.1 0 0 1-.48-.11l-4.87-2.43A13.79 13.79 0 0 1 5 19.05V6.91a1.07 1.07 0 0 1 1.05-1.07h3.47a7.45 7.45 0 0 0 4-1.19l3.87-2.48a1.07 1.07 0 0 1 1.15 0l3.87 2.48a7.45 7.45 0 0 0 4 1.19h3.47A1.07 1.07 0 0 1 31 6.91v12.14a13.79 13.79 0 0 1-7.67 12.4l-4.87 2.43A1.1 1.1 0 0 1 18 34M7.12 8v11.05a11.67 11.67 0 0 0 6.49 10.49l4.39 2.2 4.39-2.2a11.67 11.67 0 0 0 6.49-10.49V8h-2.4a9.57 9.57 0 0 1-5.19-1.53L18 4.33l-3.29 2.12A9.57 9.57 0 0 1 9.52 8z" /> + <path d="M18 18.8a4.8 4.8 0 1 1 4.8-4.8 4.81 4.81 0 0 1-4.8 4.8m0-7.47A2.67 2.67 0 1 0 20.67 14 2.67 2.67 0 0 0 18 11.34zM24.4 24.67h-2.13a2.14 2.14 0 0 0-2.13-2.13h-4.28a2.13 2.13 0 0 0-2.13 2.13H11.6a4.26 4.26 0 0 1 4.26-4.26h4.27a4.27 4.27 0 0 1 4.27 4.26" /> + </svg> +); +export default SvgSecurity; diff --git a/src/components/svg/Speaker.tsx b/src/components/svg/Speaker.tsx new file mode 100644 index 0000000..eb724ae --- /dev/null +++ b/src/components/svg/Speaker.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgSpeaker = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" {...props}> + <path d="M232.011 88.828c-5.664-5.664-13.217-8.784-21.269-8.784s-15.605 3.12-21.269 8.783c-9.917 9.917-11.446 25.09-4.593 36.632-23.293 86.372-34.167 96.094-78.604 135.776-15.831 14.138-35.533 31.731-61.302 57.5-5.434 5.434-8.426 12.673-8.426 20.383s2.993 14.949 8.426 20.383l70.981 70.98c5.434 5.435 12.672 8.427 20.382 8.427a28.7 28.7 0 0 0 14.046-3.637l72.768 72.768c2.574 2.574 6.09 3.962 9.896 3.961q1.185 0 2.398-.181c3.883-.581 7.662-2.543 10.641-5.521l25.329-25.329c6.918-6.919 7.684-16.993 1.741-22.936l-39.164-39.164c11.586-20.762 9.203-46.431-6.187-64.762 29.684-32.251 46.532-43.128 122.192-63.532a30.1 30.1 0 0 0 15.361 4.203c7.703 0 15.405-2.933 21.269-8.796 11.728-11.729 11.728-30.811 0-42.539zM127.268 419.167l-70.981-70.981c-2.412-2.411-3.74-5.632-3.74-9.068s1.328-6.657 3.74-9.068c17.786-17.786 32.665-31.645 45.371-43.163l86.911 86.911c-11.519 12.706-25.378 27.585-43.164 45.371-2.412 2.411-5.632 3.74-9.068 3.74-3.437-.001-6.657-1.33-9.069-3.742M260.1 469.653l-25.33 25.33a4.1 4.1 0 0 1-1.197.85L162.45 424.71a1244 1244 0 0 0 26.786-27.968l71.714 71.713a4 4 0 0 1-.85 1.198m-38.055-62.731-21.982-21.981a2608 2608 0 0 0 14.157-15.763l2.712-3.035c8.895 11.831 10.752 27.329 5.113 40.779m-19.759-48.401-3.004 3.362-85.711-85.711 3.361-3.003c44.419-39.665 57.85-51.661 80.687-133.656l138.322 138.322c-81.993 22.837-93.99 36.268-133.655 80.686m173.027-83.854c-5.489 5.49-14.422 5.49-19.911 0L200.786 120.052c-5.489-5.489-5.489-14.421 0-19.91 2.642-2.643 6.178-4.098 9.956-4.098s7.313 1.455 9.955 4.098l154.616 154.615c5.489 5.489 5.489 14.421 0 19.91m-22.558-151.968a8 8 0 0 1 0-11.314l43.904-43.904a8 8 0 0 1 11.313 11.314l-43.904 43.904c-1.562 1.562-3.609 2.343-5.657 2.343s-4.094-.781-5.656-2.343m122.699 107.695a8 8 0 0 1-8 8h-62.09a8 8 0 0 1 0-16h62.09a8 8 0 0 1 8 8M237.061 70.09V8a8 8 0 0 1 16 0v62.09a8 8 0 0 1-16 0" /> + </svg> +); +export default SvgSpeaker; diff --git a/src/components/svg/Sun.tsx b/src/components/svg/Sun.tsx new file mode 100644 index 0000000..61880f5 --- /dev/null +++ b/src/components/svg/Sun.tsx @@ -0,0 +1,9 @@ +import type { SVGProps } from 'react'; + +const SvgSun = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 1400" {...props}> + <path d="M367.43 422.13a54.44 54.44 0 0 1-38.66-16L205 282.35A54.69 54.69 0 0 1 282.37 205l123.74 123.79a54.68 54.68 0 0 1-38.68 93.34M1156.3 1211a54.5 54.5 0 0 1-38.67-16l-123.74-123.79a54.68 54.68 0 1 1 77.34-77.33L1195 1117.65a54.7 54.7 0 0 1-38.7 93.35m-912.6 0a54.7 54.7 0 0 1-38.7-93.35l123.74-123.76a54.69 54.69 0 0 1 77.36 77.32L282.37 1195a54.5 54.5 0 0 1-38.67 16m788.87-788.87a54.68 54.68 0 0 1-38.68-93.34L1117.61 205a54.69 54.69 0 0 1 77.39 77.35l-123.77 123.76a54.44 54.44 0 0 1-38.66 16.02M229.69 754.69h-175a54.69 54.69 0 0 1 0-109.38h175a54.69 54.69 0 0 1 0 109.38m1115.62 0h-175a54.69 54.69 0 0 1 0-109.38h175a54.69 54.69 0 0 1 0 109.38M700 1400a54.68 54.68 0 0 1-54.69-54.69v-175a54.69 54.69 0 0 1 109.38 0v175A54.68 54.68 0 0 1 700 1400m0-1115.62a54.7 54.7 0 0 1-54.69-54.69v-175a54.69 54.69 0 0 1 109.38 0v175A54.7 54.7 0 0 1 700 284.38" /> + <circle cx={700} cy={700} r={306.25} /> + </svg> +); +export default SvgSun; diff --git a/src/components/svg/Switch.tsx b/src/components/svg/Switch.tsx new file mode 100644 index 0000000..0196d85 --- /dev/null +++ b/src/components/svg/Switch.tsx @@ -0,0 +1,19 @@ +import type { SVGProps } from 'react'; + +const SvgSwitch = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={200} + height={200} + fill="none" + stroke="currentColor" + strokeLinecap="round" + strokeLinejoin="round" + strokeWidth={2} + viewBox="0 0 24 24" + {...props} + > + <path d="m16 3 4 4-4 4M10 7h10M8 13l-4 4 4 4M4 17h9" /> + </svg> +); +export default SvgSwitch; diff --git a/src/components/svg/Tag.tsx b/src/components/svg/Tag.tsx new file mode 100644 index 0000000..2ff51f4 --- /dev/null +++ b/src/components/svg/Tag.tsx @@ -0,0 +1,16 @@ +import type { SVGProps } from 'react'; + +const SvgTag = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="437pt" + height="437pt" + fill="currentColor" + viewBox="0 0 437.004 437" + {...props} + > + <path d="M229 14.645A50.17 50.17 0 0 0 192.371.015L52.293 3.586C25.672 4.25 4.246 25.673 3.582 52.298L.016 192.37a50.22 50.22 0 0 0 14.625 36.633l193.367 193.36c19.539 19.495 51.168 19.495 70.707 0l143.644-143.645c19.528-19.524 19.528-51.184 0-70.711zm179.219 249.933-143.645 143.64c-11.722 11.7-30.703 11.7-42.426 0L28.785 214.86a30.13 30.13 0 0 1-8.777-21.98l3.566-140.074c.403-15.973 13.254-28.828 29.227-29.227l140.074-3.57c.254-.004.5-.008.754-.008a30.13 30.13 0 0 1 21.223 8.79l193.367 193.362c11.695 11.723 11.695 30.703 0 42.426zm0 0" /> + <path d="M130.719 82.574c-26.59 0-48.145 21.555-48.149 48.145 0 26.59 21.559 48.144 48.145 48.144 26.59 0 48.144-21.554 48.144-48.144-.03-26.574-21.566-48.114-48.14-48.145m0 76.29c-15.547 0-28.145-12.602-28.149-28.145 0-15.543 12.602-28.145 28.145-28.145s28.144 12.602 28.144 28.145c-.015 15.535-12.605 28.125-28.14 28.144zm0 0" /> + </svg> +); +export default SvgTag; diff --git a/src/components/svg/Target.tsx b/src/components/svg/Target.tsx new file mode 100644 index 0000000..3fe76d2 --- /dev/null +++ b/src/components/svg/Target.tsx @@ -0,0 +1,21 @@ +import type { SVGProps } from 'react'; + +const SvgTarget = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width={512} + height={512} + fill="currentColor" + fillRule="evenodd" + strokeLinejoin="round" + strokeMiterlimit={2} + clipRule="evenodd" + viewBox="0 0 24 24" + {...props} + > + <path d="M19.393 10.825a.75.75 0 0 1 1.458-.352c.181.75.277 1.533.277 2.338 0 5.485-4.453 9.939-9.939 9.939S1.25 18.296 1.25 12.811s4.454-9.939 9.939-9.939c.805 0 1.588.096 2.338.277a.75.75 0 1 1-.352 1.458A8.442 8.442 0 0 0 2.75 12.811a8.44 8.44 0 0 0 8.439 8.439 8.442 8.442 0 0 0 8.204-10.425" /> + <path d="M14.764 12.811a.75.75 0 0 1 1.5 0c0 2.8-2.274 5.074-5.075 5.074a5.077 5.077 0 0 1-5.074-5.074 5.077 5.077 0 0 1 5.074-5.075.75.75 0 0 1 0 1.5 3.575 3.575 0 1 0 3.575 3.575m7.766-7.223-3.057 3.058a.75.75 0 0 1-.531.22h-3.058a.75.75 0 0 1-.75-.75V5.058a.75.75 0 0 1 .22-.531l3.058-3.057a.75.75 0 0 1 1.242.293L20.3 3.7l1.937.646a.75.75 0 0 1 .293 1.242m-1.918-.202-1.142-.381a.75.75 0 0 1-.475-.475l-.381-1.142-1.98 1.98v1.998h1.998z" /> + <path d="M15.354 7.585a.75.75 0 1 1 1.061 1.061l-4.587 4.586a.749.749 0 1 1-1.06-1.06z" /> + </svg> +); +export default SvgTarget; diff --git a/src/components/svg/Visitor.tsx b/src/components/svg/Visitor.tsx new file mode 100644 index 0000000..16db585 --- /dev/null +++ b/src/components/svg/Visitor.tsx @@ -0,0 +1,8 @@ +import type { SVGProps } from 'react'; + +const SvgVisitor = (props: SVGProps<SVGSVGElement>) => ( + <svg xmlns="http://www.w3.org/2000/svg" xmlSpace="preserve" viewBox="0 0 512 512" {...props}> + <path d="M256 0c-74.439 0-135 60.561-135 135s60.561 135 135 135 135-60.561 135-135S330.439 0 256 0m167.966 358.195C387.006 320.667 338.009 300 286 300h-60c-52.008 0-101.006 20.667-137.966 58.195C51.255 395.539 31 444.833 31 497c0 8.284 6.716 15 15 15h420c8.284 0 15-6.716 15-15 0-52.167-20.255-101.461-57.034-138.805" /> + </svg> +); +export default SvgVisitor; diff --git a/src/components/svg/Website.tsx b/src/components/svg/Website.tsx new file mode 100644 index 0000000..20a18a4 --- /dev/null +++ b/src/components/svg/Website.tsx @@ -0,0 +1,13 @@ +import type { SVGProps } from 'react'; + +const SvgWebsite = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + xmlSpace="preserve" + viewBox="0 0 511.999 511.999" + {...props} + > + <path d="M437.019 74.981C388.667 26.628 324.38 0 256 0S123.332 26.628 74.981 74.98C26.628 123.332 0 187.62 0 256s26.628 132.667 74.981 181.019c48.351 48.352 112.639 74.98 181.019 74.98s132.667-26.628 181.02-74.981C485.371 388.667 512 324.379 512 255.999s-26.629-132.667-74.981-181.018M96.216 96.216c22.511-22.511 48.938-39.681 77.742-50.888-7.672 9.578-14.851 20.587-21.43 32.969-7.641 14.38-14.234 30.173-19.725 47.042-19.022-3.157-36.647-7.039-52.393-11.595a230 230 0 0 1 15.806-17.528m-33.987 43.369c18.417 5.897 39.479 10.87 62.461 14.809-6.4 27.166-10.167 56.399-11.066 86.591H30.536c2.36-36.233 13.242-70.813 31.693-101.4m-1.635 230.053c-17.455-29.899-27.769-63.481-30.059-98.623h83.146c.982 29.329 4.674 57.731 10.858 84.186-23.454 3.802-45.045 8.649-63.945 14.437m35.622 46.146a230 230 0 0 1-17.831-20.055c16.323-4.526 34.571-8.359 54.214-11.433 5.53 17.103 12.194 33.105 19.928 47.662 7.17 13.493 15.053 25.349 23.51 35.505-29.61-11.183-56.769-28.629-79.821-51.679m144.768 62.331c-22.808-6.389-44.384-27.217-61.936-60.249-6.139-11.552-11.531-24.155-16.15-37.587 24.73-2.722 51.045-4.331 78.086-4.709zm0-132.578c-29.988.409-59.217 2.292-86.59 5.507-6.038-24.961-9.671-51.978-10.668-80.028h97.259v74.521zm0-104.553h-97.315c.911-28.834 4.602-56.605 10.828-82.201 27.198 3.4 56.366 5.468 86.487 6.06zm0-106.176c-27.146-.547-53.403-2.317-77.958-5.205 4.591-13.292 9.941-25.768 16.022-37.215 17.551-33.032 39.128-53.86 61.936-60.249zm209.733 6.372c17.874 30.193 28.427 64.199 30.749 99.804h-83.088c-.889-29.844-4.584-58.749-10.85-85.647 23.133-3.736 44.456-8.489 63.189-14.157m-34.934-44.964a230 230 0 0 1 16.914 18.91c-16.073 4.389-33.972 8.114-53.204 11.112-5.548-17.208-12.243-33.305-20.02-47.941-6.579-12.382-13.758-23.391-21.43-32.969 28.802 11.207 55.23 28.377 77.74 50.888m-144.767 174.8h97.259c-1.004 28.268-4.686 55.49-10.81 80.612-27.194-3.381-56.349-5.43-86.449-6.006zm0-30.032v-76.041c30.005-.394 59.257-2.261 86.656-5.464 6.125 25.403 9.756 52.932 10.659 81.505zm-.002-208.845zc22.808 6.389 44.384 27.217 61.936 60.249 6.178 11.627 11.601 24.318 16.24 37.848-24.763 2.712-51.108 4.309-78.177 4.674zm.002 445.976V375.657c27.12.532 53.357 2.286 77.903 5.156-4.579 13.232-9.911 25.654-15.967 37.053-17.552 33.032-39.128 53.86-61.936 60.249m144.767-62.331c-23.051 23.051-50.21 40.496-79.821 51.678 8.457-10.156 16.34-22.011 23.51-35.504 7.62-14.341 14.198-30.088 19.68-46.906 19.465 3.213 37.473 7.186 53.515 11.859a230 230 0 0 1-16.884 18.873m34.823-44.775c-18.635-5.991-40-11.032-63.326-15.01 6.296-26.68 10.048-55.36 11.041-84.983h83.146c-2.328 35.678-12.918 69.753-30.861 99.993" /> + </svg> +); +export default SvgWebsite; diff --git a/src/components/svg/index.ts b/src/components/svg/index.ts new file mode 100644 index 0000000..76756af --- /dev/null +++ b/src/components/svg/index.ts @@ -0,0 +1,37 @@ +export { default as AddUser } from './AddUser'; +export { default as BarChart } from './BarChart'; +export { default as Bars } from './Bars'; +export { default as Bolt } from './Bolt'; +export { default as Bookmark } from './Bookmark'; +export { default as Change } from './Change'; +export { default as Compare } from './Compare'; +export { default as Dashboard } from './Dashboard'; +export { default as Download } from './Download'; +export { default as Expand } from './Expand'; +export { default as Export } from './Export'; +export { default as Flag } from './Flag'; +export { default as Funnel } from './Funnel'; +export { default as Gear } from './Gear'; +export { default as Lightbulb } from './Lightbulb'; +export { default as Lightning } from './Lightning'; +export { default as Location } from './Location'; +export { default as Lock } from './Lock'; +export { default as Logo } from './Logo'; +export { default as LogoWhite } from './LogoWhite'; +export { default as Magnet } from './Magnet'; +export { default as Money } from './Money'; +export { default as Network } from './Network'; +export { default as Nodes } from './Nodes'; +export { default as Overview } from './Overview'; +export { default as Path } from './Path'; +export { default as Profile } from './Profile'; +export { default as Pushpin } from './Pushpin'; +export { default as Redo } from './Redo'; +export { default as Reports } from './Reports'; +export { default as Security } from './Security'; +export { default as Speaker } from './Speaker'; +export { default as Switch } from './Switch'; +export { default as Tag } from './Tag'; +export { default as Target } from './Target'; +export { default as Visitor } from './Visitor'; +export { default as Website } from './Website'; diff --git a/src/declaration.d.ts b/src/declaration.d.ts new file mode 100644 index 0000000..14bae12 --- /dev/null +++ b/src/declaration.d.ts @@ -0,0 +1,18 @@ +declare module '*.css'; +declare module '*.svg'; +declare module '*.json'; +declare module 'bcryptjs'; +declare module 'chartjs-adapter-date-fns'; +declare module 'cors'; +declare module 'date-fns-tz'; +declare module 'debug'; +declare module 'fs-extra'; +declare module 'jsonwebtoken'; +declare module 'md5'; +declare module 'papaparse'; +declare module 'prettier'; +declare module 'react-simple-maps'; +declare module 'semver'; +declare module 'tsup'; +declare module 'uuid'; +declare module '@umami/esbuild-plugin-css-modules'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..907c562 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,82 @@ +export * from '@/app/(main)/settings/preferences/LanguageSetting'; +export * from '@/app/(main)/settings/preferences/PreferenceSettings'; +export * from '@/app/(main)/settings/preferences/PreferencesPage'; +export * from '@/app/(main)/settings/preferences/ThemeSetting'; +export * from '@/app/(main)/teams/[teamId]/TeamDeleteForm'; +export * from '@/app/(main)/teams/[teamId]/TeamEditForm'; +export * from '@/app/(main)/teams/[teamId]/TeamManage'; +export * from '@/app/(main)/teams/[teamId]/TeamMemberEditButton'; +export * from '@/app/(main)/teams/[teamId]/TeamMemberEditForm'; +export * from '@/app/(main)/teams/[teamId]/TeamMemberRemoveButton'; +export * from '@/app/(main)/teams/[teamId]/TeamMembersDataTable'; +export * from '@/app/(main)/teams/[teamId]/TeamMembersTable'; +export * from '@/app/(main)/teams/[teamId]/TeamSettings'; +export * from '@/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton'; +export * from '@/app/(main)/teams/[teamId]/TeamWebsitesDataTable'; +export * from '@/app/(main)/teams/[teamId]/TeamWebsitesTable'; + +export * from '@/app/(main)/teams/TeamAddForm'; +export * from '@/app/(main)/teams/TeamJoinForm'; +export * from '@/app/(main)/teams/TeamLeaveButton'; +export * from '@/app/(main)/teams/TeamLeaveForm'; +export * from '@/app/(main)/teams/TeamProvider'; +export * from '@/app/(main)/teams/TeamsAddButton'; +export * from '@/app/(main)/teams/TeamsDataTable'; +export * from '@/app/(main)/teams/TeamsHeader'; +export * from '@/app/(main)/teams/TeamsJoinButton'; +export * from '@/app/(main)/teams/TeamsTable'; +export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteData'; +export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm'; +export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteEditForm'; +export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteResetForm'; +export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteSettings'; +export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteShareForm'; +export * from '@/app/(main)/websites/[websiteId]/settings/WebsiteTrackingCode'; + +export * from '@/app/(main)/websites/WebsiteAddButton'; +export * from '@/app/(main)/websites/WebsiteAddForm'; +export * from '@/app/(main)/websites/WebsiteProvider'; +export * from '@/app/(main)/websites/WebsitesDataTable'; +export * from '@/app/(main)/websites/WebsitesHeader'; +export * from '@/app/(main)/websites/WebsitesTable'; + +export * from '@/components/charts/BarChart'; +export * from '@/components/charts/BubbleChart'; +export * from '@/components/charts/Chart'; +export * from '@/components/charts/ChartTooltip'; +export * from '@/components/charts/PieChart'; + +export * from '@/components/common/ActionForm'; +export * from '@/components/common/ConfirmationForm'; +export * from '@/components/common/DataGrid'; +export * from '@/components/common/DateDisplay'; +export * from '@/components/common/DateDistance'; +export * from '@/components/common/Empty'; +export * from '@/components/common/EmptyPlaceholder'; +export * from '@/components/common/ErrorBoundary'; +export * from '@/components/common/ErrorMessage'; +export * from '@/components/common/ExternalLink'; +export * from '@/components/common/Favicon'; +export * from '@/components/common/LinkButton'; +export * from '@/components/common/LoadingPanel'; +export * from '@/components/common/PageBody'; +export * from '@/components/common/PageHeader'; +export * from '@/components/common/Pager'; +export * from '@/components/common/Panel'; +export * from '@/components/common/SectionHeader'; +export * from '@/components/common/SideMenu'; +export * from '@/components/common/TypeConfirmationForm'; +export * from '@/components/hooks'; +export * from '@/components/input/DateFilter'; +export * from '@/components/input/DialogButton'; +export * from '@/components/input/DownloadButton'; +export * from '@/components/input/ExportButton'; +export * from '@/components/input/FilterButtons'; +export * from '@/components/input/NavButton'; +export * from '@/components/input/ProfileButton'; +export * from '@/components/input/WebsiteSelect'; +export * from '@/components/metrics/ChangeLabel'; +export * from '@/components/metrics/ListTable'; +export * from '@/components/metrics/MetricCard'; +export * from '@/components/metrics/MetricLabel'; +export * from '@/components/metrics/MetricsBar'; diff --git a/src/lang/ar-SA.json b/src/lang/ar-SA.json new file mode 100644 index 0000000..5b5cfa9 --- /dev/null +++ b/src/lang/ar-SA.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "كود الدعوة", + "label.actions": "الإجراءات", + "label.activity": "سجل الأحداث", + "label.add": "أضِف", + "label.add-board": "أضف لوحة", + "label.add-description": "أضِف وصف", + "label.add-member": "أضِف عضو", + "label.add-step": "إضافة خطوة", + "label.add-website": "إضافة موقع", + "label.admin": "مدير", + "label.affiliate": "Affiliate", + "label.after": "يعد", + "label.all": "الكل", + "label.all-time": "كل الوقت", + "label.analytics": "تحليلات", + "label.apply": "تطبيق", + "label.attribution": "الإسناد", + "label.attribution-description": "شاهد كيف يتفاعل المستخدمون مع حملاتك التسويقية وما الذي يحفز التحويلات.", + "label.average": "المتوسط", + "label.back": "للخلف", + "label.before": "قبل", + "label.boards": "لوحات", + "label.bounce-rate": "معدل الارتداد", + "label.breakdown": "التصنيف", + "label.browser": "المتصفح", + "label.browsers": "المتصفحات", + "label.campaigns": "حملات", + "label.cancel": "إلغاء", + "label.change-password": "تغيير كلمة المرور", + "label.channels": "قنوات", + "label.cities": "المدن", + "label.city": "المدينة", + "label.clear-all": "مسح الكل", + "label.cohort": "مجموعة", + "label.compare": "المقارنة", + "label.compare-dates": "قارن التواريخ", + "label.confirm": "تأكيد", + "label.confirm-password": "تأكيد كلمة المرور", + "label.contains": "يحتوي على", + "label.content": "المحتوى", + "label.continue": "تابع", + "label.conversion": "تحويل", + "label.conversion-rate": "معدل التحويل", + "label.conversion-step": "خطوة التحويل", + "label.count": "العدد", + "label.countries": "الدول", + "label.country": "الدولة", + "label.create": "أنشِئ", + "label.create-report": "أنشِئ تقرير", + "label.create-team": "أنشِئ فريق", + "label.create-user": "أنشِئ مستخدم", + "label.created": "أُنشئت", + "label.created-by": "أُنشئ من قبل", + "label.currency": "العملة", + "label.current": "الحالي", + "label.current-password": "كلمة المرور الحالية", + "label.custom-range": "فترة مخصّصة", + "label.dashboard": "لوحة التحكم", + "label.data": "البيانات", + "label.date": "التاريخ", + "label.date-range": "فترة مخصّصة", + "label.day": "يوم", + "label.default-date-range": "الفترة المخصّصة الافتراضية", + "label.delete": "حذف", + "label.delete-report": "احذف التقرير", + "label.delete-team": "حذف الفريق", + "label.delete-user": "جذف مستخدم", + "label.delete-website": "حذف الموقع", + "label.description": "الوصف", + "label.desktop": "كمبيوتر", + "label.details": "تفاصيل", + "label.device": "الجهاز", + "label.devices": "الأجهزة", + "label.direct": "مباشر", + "label.dismiss": "تجاهل", + "label.distinct-id": "معرّف مميز", + "label.does-not-contain": "لا يحتوي على", + "label.does-not-include": "لا يتضمن", + "label.doest-not-exist": "غير موجود", + "label.domain": "النطاق", + "label.dropoff": "إنزال", + "label.edit": "تعديل", + "label.edit-dashboard": "عدّل لوحة التحكم", + "label.edit-member": "عدّل العضو", + "label.email": "Email", + "label.enable-share-url": "فعّل مشاركة الرابط", + "label.end-step": "الخطوة الأخيرة", + "label.entry": "رابط الدخول", + "label.event": "الحدث", + "label.event-data": "تاريخ الحدث", + "label.event-name": "اسم الحدث", + "label.events": "الأحداث", + "label.exists": "موجود", + "label.exit": "رابط المغادرة", + "label.false": "خطأ", + "label.field": "الحقل", + "label.fields": "الحقول", + "label.filter": "تصفيَة", + "label.filter-combined": "مُجمّعة", + "label.filter-raw": "خام", + "label.filters": "التصفيات", + "label.first-click": "النقرة الأولى", + "label.first-seen": "أول ظهور", + "label.funnel": "قمع", + "label.funnel-description": "فهم معدل التحويل والانقطاع عن المستخدمين.", + "label.funnels": "قمعات", + "label.goal": "الهدف", + "label.goals": "الأهداف", + "label.goals-description": "تابع تحقق أهدافك المرتبطة بمشاهدات الصفحات والأحداث.", + "label.greater-than": "أكبَر مِن", + "label.greater-than-equals": "أكبَر مِن أو يساوي", + "label.grouped": "مجمع", + "label.hostname": "اسم المضيف", + "label.includes": "يتضمن", + "label.insight": "رؤية معمقة", + "label.insights": "نتائج التحليلات", + "label.insights-description": "تعمق في بياناتك باستخدام الشرائح والتصفيات.", + "label.is": "يساوي", + "label.is-false": "غير صحيح", + "label.is-not": "لا يساوي", + "label.is-not-set": "لم ضُبط", + "label.is-set": "ضُبط", + "label.is-true": "صحيح", + "label.join": "انضم", + "label.join-team": "انضم للفريق", + "label.journey": "رحلة المستخدم", + "label.journey-description": "تعرّف على كيفية تنقّل المستخدمين داخل موقعك.", + "label.journeys": "رحلات المستخدم", + "label.language": "اللغة", + "label.languages": "اللغات", + "label.laptop": "لابتوب", + "label.last-click": "النقرة الأخيرة", + "label.last-days": "آخر {x} يوم/ايام", + "label.last-hours": "آخر {x} ساعة", + "label.last-months": "آخر {x} شهر/أشهر", + "label.last-seen": "آخر ظهور", + "label.leave": "غادر", + "label.leave-team": "مغادرة المجموعة", + "label.less-than": "أقل مِن", + "label.less-than-equals": "أقل مِن أو يساوي", + "label.links": "روابط", + "label.login": "تسجيل الدخول", + "label.logout": "تسجيل الخروج", + "label.manage": "التحكم", + "label.manager": "مدير", + "label.max": "الحد الأقصى", + "label.maximize": "توسيع", + "label.medium": "وسيط", + "label.member": "عضو", + "label.members": "الأعضاء", + "label.min": "الحد الأدنى", + "label.mobile": "جوال", + "label.model": "نموذج", + "label.more": "المزيد", + "label.my-account": "حسابي", + "label.my-websites": "مواقعي", + "label.name": "الاسم", + "label.new-password": "كلمة مرور جديدة", + "label.none": "لا شيء", + "label.number-of-records": "{x} {x, plural, one {سجل} other {سجلات}}", + "label.ok": "نعم", + "label.online": "Online", + "label.organic-search": "بحث عضوي", + "label.organic-shopping": "تسوق عضوي", + "label.organic-social": "اجتماعي عضوي", + "label.organic-video": "فيديو عضوي", + "label.os": "نظام التشغيل", + "label.other": "أخرى", + "label.overview": "نظرة عامة", + "label.owner": "المالك", + "label.page": "صفحة", + "label.page-of": "صفحة {current} من {total}", + "label.page-views": "مشاهدات الصفحة", + "label.pageTitle": "عنوان الصفحة", + "label.pages": "صفحات", + "label.paid-ads": "إعلانات مدفوعة", + "label.paid-search": "بحث مدفوع", + "label.paid-shopping": "تسوق مدفوع", + "label.paid-social": "اجتماعي مدفوع", + "label.paid-video": "فيديو مدفوع", + "label.password": "كلمة المرور", + "label.path": "مسار", + "label.paths": "مسارات", + "label.pixels": "بكسلات", + "label.powered-by": "مشغل بواسطة {name}", + "label.previous": "السابق", + "label.previous-period": "الفترة السابقة", + "label.previous-year": "العام السابق", + "label.profile": "الملف الشخصي", + "label.properties": "خصائص", + "label.property": "خاصية", + "label.queries": "استعلامات", + "label.query": "استعلام", + "label.query-parameters": "معاملات الاستعلام", + "label.realtime": "الوقت الفعلي", + "label.referral": "إحالة", + "label.referrer": "المرجع", + "label.referrers": "التحويلات", + "label.refresh": "تحديث", + "label.regenerate": "إعادة توليد", + "label.region": "المنطقة", + "label.regions": "المناطق", + "label.remaining": "متبقي", + "label.remove": "أزِل", + "label.remove-member": "احذف عضو", + "label.reports": "التقارير", + "label.required": "اجباري", + "label.reset": "اعادة تعيين", + "label.reset-website": "اعادة تعيين الإحصائيات", + "label.retention": "الاحتفاظ", + "label.retention-description": "قس مدى ثبات موقعك على الويب من خلال تتبع عدد مرات عودة المستخدمين.", + "label.revenue": "الإيرادات", + "label.revenue-description": "قم بإلقاء نظرة على بيانات إيراداتك وكيفية إنفاق المستخدمين.", + "label.role": "الصلاحية", + "label.run-query": "شغّل الاستعلام", + "label.save": "حفظ", + "label.screens": "الشاشات", + "label.search": "بحث", + "label.select": "اختر", + "label.select-date": "حدد التاريخ", + "label.select-filter": "اختر تصفية", + "label.select-role": "حدد الدور", + "label.select-website": "حدد موقع", + "label.session": "الزيارة", + "label.session-data": "بيانات الجلسة", + "label.sessions": "الزيارات", + "label.settings": "الإعدادات", + "label.share": "مشاركة", + "label.share-url": "مشاركة الرابط", + "label.single-day": "يوم واحد", + "label.sms": "SMS", + "label.sources": "مصادر", + "label.start-step": "الخطوة الأولى", + "label.steps": "الخطوات", + "label.sum": "المجموع", + "label.tablet": "تابلت", + "label.tag": "الوسم", + "label.tags": "الوسوم", + "label.team": "الفريق", + "label.team-id": "معرّف الفريق", + "label.team-manager": "مدير الفريق", + "label.team-member": "عضو الفريق", + "label.team-name": "اسم الفريق", + "label.team-owner": "مدير الفريق", + "label.team-settings": "إعدادات الفريق", + "label.team-view-only": "عرض الفريق فقط", + "label.team-websites": "مواقع الفريق", + "label.teams": "الفرق", + "label.terms": "مصطلحات", + "label.theme": "السمة", + "label.this-month": "الشهر الحالي", + "label.this-week": "الاسبوع الحالي", + "label.this-year": "السنة الحالية", + "label.timezone": "المنطقة الزمنية", + "label.title": "العنوان", + "label.today": "اليوم", + "label.toggle-charts": "تغيير الإحصائيات", + "label.total": "الإجمالي", + "label.total-records": "إجمالي السجلات", + "label.tracking-code": "كود التتبع", + "label.transactions": "المعاملات", + "label.transfer": "نقل", + "label.transfer-website": "انقل الموقع", + "label.true": "حقيقي", + "label.type": "النوع", + "label.unique": "فريد", + "label.unique-visitors": "زائرون فريدون", + "label.uniqueCustomers": "العملاء الفريدون", + "label.unknown": "غير معروف", + "label.untitled": "بدون عنوان", + "label.update": "تحديث", + "label.user": "المستخدم", + "label.username": "اسم المستخدم", + "label.users": "المستخدمين", + "label.utm": "UTM", + "label.utm-description": "تابع حملاتك التسويقية باستخدام معلمات UTM.", + "label.value": "القيمة", + "label.view": "عرض", + "label.view-details": "عرض التفاصيل", + "label.view-only": "عرض فقط", + "label.views": "المشاهدات", + "label.views-per-visit": "مشاهدات لكل زيارة", + "label.visit-duration": "متوسط وقت الزيارة", + "label.visitors": "الزوار", + "label.visits": "الزيارات", + "label.website": "الموقع", + "label.website-id": "معرّف الموقع", + "label.websites": "المواقع", + "label.window": "النافذة", + "label.yesterday": "الأمس", + "label.behavior": "السلوك", + "message.action-confirmation": "اكتب {confirmation} في المربع أدناه للتأكيد.", + "message.active-users": "{x} حاليا {x, plural, one {زائر واحد} other {زوار}}", + "message.bad-request": "Bad request", + "message.collected-data": "البيانات المجمعة", + "message.confirm-delete": "هل أنت متأكد من حذف {target}?", + "message.confirm-leave": "هل أنت متأكد من مغادرة {target}?", + "message.confirm-remove": "هل انت متأكد من حذف {target}?", + "message.confirm-reset": "هل أنت متأكد من اعادة تعيين الإحصائيات لـ {target}؟", + "message.delete-team-warning": "سيؤدي حذف الفريق أيضًا إلى حذف كافة مواقع الفريق", + "message.delete-website-warning": "سيتم حذف كافة بيانات الموقع.", + "message.error": "حدث خطأ ما.", + "message.event-log": "{event} في {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "الذهاب إلى الإعدادات", + "message.incorrect-username-password": "اسم المستخدم او كلمة المرور غير صحيحة.", + "message.invalid-domain": "النطاق غير صحيح", + "message.min-password-length": "اقل عدد مسموح به {n} حرف/أحرف", + "message.new-version-available": "إصدار جديد من Umami {version} متاح!", + "message.no-data-available": "لا توجد بيانات متاحة.", + "message.no-event-data": "لا توجد بيانات الحدث متاحة.", + "message.no-match-password": "كلمة المرور غير متطابقة", + "message.no-results-found": "لا توجد نتائج.", + "message.no-team-websites": "هذا الفريق ليس لديه أي مواقع.", + "message.no-teams": "لم تنشِئ اي فرق.", + "message.no-users": "لا يوجد مستخدمين.", + "message.no-websites-configured": "لم تقم بإعداد اي موقع.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "الصفحة غير موجودة.", + "message.reset-website": "لإعادة ضبط موقع الويب هذا، اكتب {confirmation} في المربع أدناه للتأكيد.", + "message.reset-website-warning": "سيتم اعادة تعيين كافة الإحصائيات لهذا الموقع، لكن لن يتم تغيير كود التتبع", + "message.saved": "تم الحفظ بنجاح.", + "message.sever-error": "Server error", + "message.share-url": "إحصائيات موقعك متاحة للجميع على الرابط التالي:", + "message.team-already-member": "أنت عضو في الفريق", + "message.team-not-found": "لم يتم العثور على الفريق", + "message.team-websites-info": "يمكن مشاهدة الموقع من اي عضو في الفريق.", + "message.tracking-code": "كود التتبع", + "message.transfer-team-website-to-user": "نقل هذا الموقع إلى حسابك؟", + "message.transfer-user-website-to-team": "اختر الفريق الذي تريد نقل الموقع إليه.", + "message.transfer-website": "نقل ملكية الموقع لحسابك أو فريق أخر.", + "message.triggered-event": "أُطلق الحدث", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "تم حذف المستخدم.", + "message.viewed-page": "شوهدت الصفحة", + "message.visitor-log": "زائر من {country} يستخدم {browser} على {os} {device}" +} diff --git a/src/lang/be-BY.json b/src/lang/be-BY.json new file mode 100644 index 0000000..1a866a9 --- /dev/null +++ b/src/lang/be-BY.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Код доступу", + "label.actions": "Дзеянні", + "label.activity": "Журнал актыўнасці", + "label.add": "Дадаць", + "label.add-board": "Дадаць дошку", + "label.add-description": "Дадаць апісанне", + "label.add-member": "Дадаць удзельніка", + "label.add-step": "Дадаць крок", + "label.add-website": "Дадаць сайт", + "label.admin": "Адміністратар", + "label.affiliate": "Партнёр", + "label.after": "Пасля", + "label.all": "Усё", + "label.all-time": "Увесь час", + "label.analytics": "Аналітыка", + "label.apply": "Ужыць", + "label.attribution": "Атрыбуцыя", + "label.attribution-description": "Глядзіце, як карыстальнікі ўзаемадзейнічаюць з вашым маркетынгам і што прыводзіць да канверсій.", + "label.average": "Сярэдняе", + "label.back": "Назад", + "label.before": "Да", + "label.behavior": "Паводзіны", + "label.boards": "Дошкі", + "label.bounce-rate": "Паказчык адмоваў", + "label.breakdown": "Разбіўка", + "label.browser": "Браўзер", + "label.browsers": "Браўзеры", + "label.campaigns": "Кампаніі", + "label.cancel": "Адмена", + "label.change-password": "Змяніць пароль", + "label.channels": "Каналы", + "label.cities": "Гарады", + "label.city": "Горад", + "label.clear-all": "Ачысціць усё", + "label.cohort": "Кагорта", + "label.compare": "Параўнаць", + "label.compare-dates": "Параўнаць даты", + "label.confirm": "Падцвердзіць", + "label.confirm-password": "Падцвердзіць пароль", + "label.contains": "Уключае", + "label.content": "Змест", + "label.continue": "Працягнуць", + "label.conversion": "Канверсія", + "label.conversion-rate": "Канверсійная стаўка", + "label.conversion-step": "Крок канверсіі", + "label.count": "Колькасць", + "label.countries": "Краіны", + "label.country": "Краіна", + "label.create": "Стварыць", + "label.create-report": "Стварыць справаздачу", + "label.create-team": "Стварыць каманду", + "label.create-user": "Стварыць карыстальніка", + "label.created": "Створана", + "label.created-by": "Створана", + "label.currency": "Валюта", + "label.current": "Цяперашні", + "label.current-password": "Цяперашні пароль", + "label.custom-range": "Іншы дыяпазон", + "label.dashboard": "Інфармацыйная панэль", + "label.data": "Дадзеныя", + "label.date": "Дата", + "label.date-range": "Дыяпазон дат", + "label.day": "Дзень", + "label.default-date-range": "Дыяпазон дат па змаўчанню", + "label.delete": "Выдаліць", + "label.delete-report": "Выдаліць справаздачу", + "label.delete-team": "Выдаліць каманду", + "label.delete-user": "Выдаліць карыстальніка", + "label.delete-website": "Выдаліць сайт", + "label.description": "Апісанне", + "label.desktop": "Настольны ПК", + "label.details": "Дэталі", + "label.device": "Прылада", + "label.devices": "Прылады", + "label.direct": "Прама", + "label.dismiss": "Адхіліць", + "label.distinct-id": "Унікальны ID", + "label.does-not-contain": "Не ўключае", + "label.does-not-include": "Не ўключае", + "label.doest-not-exist": "Не існуе", + "label.domain": "Дамен", + "label.dropoff": "Адмовы", + "label.edit": "Змяніць", + "label.edit-dashboard": "Змяніць інфармацыйную панэль", + "label.edit-member": "Рэдагаваць удзельніка", + "label.email": "Email", + "label.enable-share-url": "Дазволіць дзяліцца спасылкай", + "label.end-step": "Канчатковы крок", + "label.entry": "URL уваходу", + "label.event": "Падзея", + "label.event-data": "Дадзеныя падзеі", + "label.event-name": "Назва падзеі", + "label.events": "Падзеі", + "label.exists": "Існуе", + "label.exit": "URL выхаду", + "label.false": "Ложна", + "label.field": "Поле", + "label.fields": "Палі", + "label.filter": "Фільтр", + "label.filter-combined": "Камбініраваны", + "label.filter-raw": "Сырыя", + "label.filters": "Фільтры", + "label.first-click": "Першы клік", + "label.first-seen": "Першы раз убачана", + "label.funnel": "Варонка", + "label.funnel-description": "Разумець паказчыкі канверсіі і адмоваў.", + "label.funnels": "Варонкі", + "label.goal": "Мэта", + "label.goals": "Мэты", + "label.goals-description": "Сачыць за мэтамі па праглядах старонак і падзеях.", + "label.greater-than": "Больш чым", + "label.greater-than-equals": "Больш чым або роўна", + "label.grouped": "Групаваны", + "label.hostname": "Імя хаста", + "label.includes": "Уключае", + "label.insight": "Інсайт", + "label.insights": "Інсайты", + "label.insights-description": "Даследваць дадзеныя з дапамогай сегментаў і фільтраў.", + "label.is": "З'яўляецца", + "label.is-false": "Ложна", + "label.is-not": "Не з'яўляецца", + "label.is-not-set": "Не ўстаноўлена", + "label.is-set": "Устаноўлена", + "label.is-true": "Праўда", + "label.join": "Далучыцца", + "label.join-team": "Далучыцца да каманды", + "label.journey": "Маршрут карыстальніка", + "label.journey-description": "Разумець як карыстальнікі навігуюць па сайце.", + "label.journeys": "Маршруты", + "label.language": "Мова", + "label.languages": "Мовы", + "label.laptop": "Ноўтбук", + "label.last-click": "Апошні клік", + "label.last-days": "Апошнія {x} дзён", + "label.last-hours": "Апошнія {x} гадзіны", + "label.last-months": "Апошнія {x} месяцаў", + "label.last-seen": "Last seen", + "label.leave": "Пакінуць", + "label.leave-team": "Пакінуць каманду", + "label.less-than": "Менш чым", + "label.less-than-equals": "Менш чым або роўна", + "label.links": "Спасылкі", + "label.login": "Увайсці", + "label.logout": "Выйсці", + "label.manage": "Кіраваць", + "label.manager": "Кіраўнік", + "label.max": "Максімум", + "label.maximize": "Разгарнуць", + "label.medium": "Сярэдні", + "label.member": "Удзельнік", + "label.members": "Удзельнікі", + "label.min": "Мінімум", + "label.mobile": "Мабільны", + "label.model": "Мадэль", + "label.more": "Болей", + "label.my-account": "Мой уліковы запіс", + "label.my-websites": "Мае сайты", + "label.name": "Імя", + "label.new-password": "Новы пароль", + "label.none": "Няма", + "label.number-of-records": "{x} {x, plural, one {запіс} other {запісаў}}", + "label.ok": "ОК", + "label.online": "Online", + "label.organic-search": "Арганічны пошук", + "label.organic-shopping": "Арганічныя пакупкі", + "label.organic-social": "Арганічныя сацыяльныя сеткі", + "label.organic-video": "Арганічнае відэа", + "label.os": "Аперацыйная сістэма", + "label.other": "Іншае", + "label.overview": "Агляд", + "label.owner": "Уласнік", + "label.page": "Старонка", + "label.page-of": "Старонка {current} з {total}", + "label.page-views": "Прагляды старонкі", + "label.pageTitle": "Загаловак старонкі", + "label.pages": "Старонкі", + "label.paid-ads": "Платная рэклама", + "label.paid-search": "Платаны пошук", + "label.paid-shopping": "Платныя пакупкі", + "label.paid-social": "Платныя сацыяльныя сеткі", + "label.paid-video": "Платнае відэа", + "label.password": "Пароль", + "label.path": "Шлях", + "label.paths": "Шляхи", + "label.pixels": "Пікселі", + "label.powered-by": "Зроблена {name}", + "label.previous": "Папярэдні", + "label.previous-period": "Папярэдні перыяд", + "label.previous-year": "Папярэдні год", + "label.profile": "Профіль", + "label.properties": "Уласцівасці", + "label.property": "Уласцівасць", + "label.queries": "Запыты", + "label.query": "Запыт", + "label.query-parameters": "Параметры запыту", + "label.realtime": "У рэяльным часе", + "label.referral": "Рэферал", + "label.referrer": "Рэферэр", + "label.referrers": "Рэферэры", + "label.refresh": "Аднавіць", + "label.regenerate": "Рэгенераваць", + "label.region": "Рэгіён", + "label.regions": "Рэгіёны", + "label.remaining": "Засталося", + "label.remove": "Выдаліць", + "label.remove-member": "Выдаліць удзельніка", + "label.reports": "Справаздачы", + "label.required": "Абавязкова", + "label.reset": "Скінуць", + "label.reset-website": "Скінуць статыстыку", + "label.retention": "Утрыманне", + "label.retention-description": "Ацаніць прыцягальнасць сайта, адсочваючы павяртанні карыстальнікаў.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "Роля", + "label.run-query": "Запусціць запыт", + "label.save": "Захаваць", + "label.screens": "Экраны", + "label.search": "Пошук", + "label.select": "Выбраць", + "label.select-date": "Выбраць дату", + "label.select-filter": "Выбраць фільтр", + "label.select-role": "Выбраць ролю", + "label.select-website": "Выбраць сайт", + "label.session": "Сесія", + "label.session-data": "Дадзеныя сесіі", + "label.sessions": "Сесіі", + "label.settings": "Налады", + "label.share": "Падзяліцца", + "label.share-url": "Падзяліцца спасылкай", + "label.single-day": "Адзін дзень", + "label.sms": "SMS", + "label.sources": "Крыніцы", + "label.start-step": "Першы кроку", + "label.steps": "Крокі", + "label.sum": "Сума", + "label.tablet": "Планшэт", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Каманда", + "label.team-id": "Ідэнтыфікатар каманды", + "label.team-manager": "Кіраўнік каманды", + "label.team-member": "Удзельнік каманды", + "label.team-name": "Назва каманды", + "label.team-owner": "Уласнік каманды", + "label.team-settings": "Налады каманды", + "label.team-view-only": "Толькі для каманднага прагляду", + "label.team-websites": "Сайты каманды", + "label.teams": "Каманды", + "label.terms": "Тэрміны", + "label.theme": "Тэма", + "label.this-month": "Гэты месяц", + "label.this-week": "Гэты тыдзень", + "label.this-year": "Гэты год", + "label.timezone": "Часавы пояс", + "label.title": "Загаловак", + "label.today": "Сёння", + "label.toggle-charts": "Пераключыць графікі", + "label.total": "Агульная колькасць", + "label.total-records": "Агульная колькасць запісаў", + "label.tracking-code": "Код адсочвання", + "label.transactions": "Transactions", + "label.transfer": "Перадаць", + "label.transfer-website": "Перадаць сайт", + "label.true": "Ісціна", + "label.type": "Тып", + "label.unique": "Унікальны", + "label.unique-visitors": "Унікальныя наведвальнікі", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Невядома", + "label.untitled": "Без назвы", + "label.update": "Абнавіць", + "label.user": "Карыстальнік", + "label.username": "Імя карыстальніка", + "label.users": "Карыстальнікі", + "label.utm": "UTM", + "label.utm-description": "Сачыць за кампаніямі з дапамогай UTM-метак.", + "label.value": "Значэнне", + "label.view": "Паглядзець", + "label.view-details": "Паглядзець дэталі", + "label.view-only": "Толькі прагляд", + "label.views": "Прагляды", + "label.views-per-visit": "Прагляды за наведванне", + "label.visit-duration": "Сярэдняя даўжыня наведвання", + "label.visitors": "Наведвальнікі", + "label.visits": "Наведванні", + "label.website": "Сайт", + "label.website-id": "Ідэнтыфікатар сайта", + "label.websites": "Сайты", + "label.window": "Вакно", + "label.yesterday": "Учора", + "message.action-confirmation": "Увядзіце {confirmation} у поле ніжэй, каб пацвердзіць.", + "message.active-users": "{x} цякучых {x, plural, one {наведвальнік} other {наведвальнікаў}}", + "message.bad-request": "Bad request", + "message.collected-data": "Сабраныя дадзеныя", + "message.confirm-delete": "Вы дакладна хочаце выдаліць {target}?", + "message.confirm-leave": "Вы дакладна хочаце пакінуць {target}?", + "message.confirm-remove": "Вы дакладна хочаце выдаліць {target}?", + "message.confirm-reset": "Вы дакладна хочаце скінуць {target} статыстыку?", + "message.delete-team-warning": "Выдаленне каманды таксама выдаліць усе сайты каманды.", + "message.delete-website-warning": "Усе асацыяваныя дадзеныя будуць таксама выдалены.", + "message.error": "Нешта пайшло не так.", + "message.event-log": "{event} на {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Да налад", + "message.incorrect-username-password": "Некарэктнае імя карыстальніка/пароль.", + "message.invalid-domain": "Некарэктны дамен", + "message.min-password-length": "Мінімальная даўжыня {n} знакаў", + "message.new-version-available": "Даступная новая версія Umami {version}!", + "message.no-data-available": "Няма дадзеных.", + "message.no-event-data": "Дадзеныя падзеі недаступныя.", + "message.no-match-password": "Паролі не супадаюць", + "message.no-results-found": "Вынікаў не знойдзена.", + "message.no-team-websites": "Гэтая каманда не мае ніводнага сайта.", + "message.no-teams": "Вы не стварылі ніводнай каманды.", + "message.no-users": "Няма карыстальнікаў.", + "message.no-websites-configured": "Вы не наладзілі ніводнага сайта.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Старонка не знойдзена.", + "message.reset-website": "Каб скінуць гэты сайт, увядзіце {confirmation} у поле ніжэй для пацверджання.", + "message.reset-website-warning": "Уся статыстыка для гэтага сайта будзе выдалена, але код адсочвання будзе працягваць працаваць.", + "message.saved": "Захавана паспяхова.", + "message.sever-error": "Server error", + "message.share-url": "Гэта публічная спасылка для {target}.", + "message.team-already-member": "Вы ўжо ўдзельнік каманды.", + "message.team-not-found": "Каманда не знойдзена.", + "message.team-websites-info": "Сайты могуць быць праглядацца любым удзельнікам каманды.", + "message.tracking-code": "Код адсочвання", + "message.transfer-team-website-to-user": "Перадаць гэты сайт на ваш уліковы запіс?", + "message.transfer-user-website-to-team": "Выберыце каманду для перадачы гэтага сайта.", + "message.transfer-website": "Перадача сайта на ваш уліковы запіс або іншай камандзе.", + "message.triggered-event": "Падзея якая спрацавала", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Карыстальнік выдалены.", + "message.viewed-page": "Праглядзеў старонку", + "message.visitor-log": "Наведвальнік з {country} праз {browser} на {os} {device}" +} diff --git a/src/lang/bg-BG.json b/src/lang/bg-BG.json new file mode 100644 index 0000000..4b0effc --- /dev/null +++ b/src/lang/bg-BG.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Код за достъп", + "label.actions": "Действия", + "label.activity": "Активностти", + "label.add": "Добави", + "label.add-board": "Добави дъска", + "label.add-description": "Добави описание", + "label.add-member": "Добави член", + "label.add-step": "Добави стъпка", + "label.add-website": "Добави уебсайт", + "label.admin": "Администратор", + "label.affiliate": "Партньор", + "label.after": "След", + "label.all": "Всички", + "label.all-time": "За всички времена", + "label.analytics": "Анализи", + "label.apply": "Приложи", + "label.attribution": "Атрибуция", + "label.attribution-description": "Вижте как потребителите взаимодействат с вашия маркетинг и какво води до конверсии.", + "label.average": "Средно", + "label.back": "Назад", + "label.before": "Преди", + "label.behavior": "Поведение", + "label.boards": "Дъски", + "label.bounce-rate": "Kоефициент на отказ", + "label.breakdown": "Разбивка", + "label.browser": "Браузър", + "label.browsers": "Браузъри", + "label.campaigns": "Кампании", + "label.cancel": "Отмени", + "label.change-password": "Смени парола", + "label.channels": "Канали", + "label.cities": "Градове", + "label.city": "Град", + "label.clear-all": "Изчисти всички", + "label.cohort": "Кохорта", + "label.compare": "Сравни", + "label.compare-dates": "Сравни дати", + "label.confirm": "Потвърди", + "label.confirm-password": "Потвърди парола", + "label.contains": "Съдържа", + "label.content": "Съдържание", + "label.continue": "Продължи", + "label.conversion": "Конверсия", + "label.conversion-rate": "Процент на конверсия", + "label.conversion-step": "Стъпка на конверсия", + "label.count": "Брой", + "label.countries": "Държави", + "label.country": "Държава", + "label.create": "Създай", + "label.create-report": "Създай отчет", + "label.create-team": "Създай екип", + "label.create-user": "Създай потребител", + "label.created": "Създадено", + "label.created-by": "Създадено от", + "label.currency": "Валута", + "label.current": "Текущ", + "label.current-password": "Текуща парола", + "label.custom-range": "Обхват", + "label.dashboard": "Табло", + "label.data": "Данни", + "label.date": "Дата", + "label.date-range": "Диапазон от дати", + "label.day": "Ден", + "label.default-date-range": "Диапазон от дати по подразбиране", + "label.delete": "Изтрий", + "label.delete-report": "Изтрий отчет", + "label.delete-team": "Изтрий екип", + "label.delete-user": "Изтрий потребител", + "label.delete-website": "Изтрий уебсайт", + "label.description": "Описание", + "label.desktop": "Десктоп", + "label.details": "Детайли", + "label.device": "Устройство", + "label.devices": "Устройства", + "label.direct": "Директно", + "label.dismiss": "Отхвърли", + "label.distinct-id": "Уникален ID", + "label.does-not-contain": "Не съдържа", + "label.does-not-include": "Не включва", + "label.doest-not-exist": "Не съществува", + "label.domain": "Домейн", + "label.dropoff": "Отпадане", + "label.edit": "Редактирай", + "label.edit-dashboard": "Редактирай табло", + "label.edit-member": "Редактирай член", + "label.email": "Имейл", + "label.enable-share-url": "Активирай Линк за споделяне", + "label.end-step": "Крайна стъпка", + "label.entry": "URL на вход", + "label.event": "Събитие", + "label.event-data": "Данни за събитие", + "label.event-name": "Име на събитие", + "label.events": "Събития", + "label.exists": "Съществува", + "label.exit": "Exit URL", + "label.false": "Грешно", + "label.field": "Поле", + "label.fields": "Полета", + "label.filter": "Филтър", + "label.filter-combined": "Комбиниран", + "label.filter-raw": "Суров", + "label.filters": "Филтри", + "label.first-click": "Първо кликване", + "label.first-seen": "Първо видяно", + "label.funnel": "Фуния", + "label.funnel-description": "Разберете процента на конверсия и отпадане на потребителите.", + "label.funnels": "Фунии", + "label.goal": "Цел", + "label.goals": "Цели", + "label.goals-description": "Следете целите си за прегледи на страници и събития.", + "label.greater-than": "По-голямо от", + "label.greater-than-equals": "По-голямо или равно на", + "label.grouped": "Групирано", + "label.hostname": "Име на хост", + "label.includes": "Включва", + "label.insight": "Прозрение", + "label.insights": "Изводи", + "label.insights-description": "Навлезте по-дълбоко в данните си, като използвате сегменти и филтри.", + "label.is": "Е", + "label.is-false": "Грешно", + "label.is-not": "Не е", + "label.is-not-set": "Не е зададено", + "label.is-set": "Зададено е", + "label.is-true": "Вярно", + "label.join": "Присъедини се", + "label.join-team": "Присъедини се към екип", + "label.journey": "Пътешествие", + "label.journey-description": "Разберете как потребителите навигират във вашия уебсайт.", + "label.journeys": "Пътешествия", + "label.language": "Език", + "label.languages": "Езици", + "label.laptop": "Лаптоп", + "label.last-click": "Последно кликване", + "label.last-days": "Последните {x} дни", + "label.last-hours": "Последните {x} часа", + "label.last-months": "Последните {x} месеца", + "label.last-seen": "Last seen", + "label.leave": "Напусни", + "label.leave-team": "Напусни екип", + "label.less-than": "По-малко от", + "label.less-than-equals": "По-малко или равно на", + "label.links": "Връзки", + "label.login": "Вход", + "label.logout": "Изход", + "label.manage": "Управлявай", + "label.manager": "Мениджър", + "label.max": "Максимум", + "label.maximize": "Разшири", + "label.medium": "Среден", + "label.member": "Член", + "label.members": "Членове", + "label.min": "Минимум", + "label.mobile": "Мобилен", + "label.model": "Модел", + "label.more": "Още", + "label.my-account": "Моят акаунт", + "label.my-websites": "Моите уебсайтове", + "label.name": "Име", + "label.new-password": "Нова парола", + "label.none": "Няма", + "label.number-of-records": "{x} {x, plural, one {един} other {други}}", + "label.ok": "Добре", + "label.online": "Online", + "label.organic-search": "Органично търсене", + "label.organic-shopping": "Органично пазаруване", + "label.organic-social": "Органични социални мрежи", + "label.organic-video": "Органично видео", + "label.os": "ОС", + "label.other": "Друго", + "label.overview": "Общ преглед", + "label.owner": "Собственик", + "label.page": "Страница", + "label.page-of": "Страница {current} от {total}", + "label.page-views": "Прегледи на страницата", + "label.pageTitle": "Заглавие на страница", + "label.pages": "Страници", + "label.paid-ads": "Платени реклами", + "label.paid-search": "Платено търсене", + "label.paid-shopping": "Платено пазаруване", + "label.paid-social": "Платени социални мрежи", + "label.paid-video": "Платено видео", + "label.password": "Парола", + "label.path": "Път", + "label.paths": "Пътища", + "label.pixels": "Пиксели", + "label.powered-by": "Поддържано от {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Профил", + "label.properties": "Свойства", + "label.property": "Свойство", + "label.queries": "Запитвания", + "label.query": "Запитване", + "label.query-parameters": "Параметри на търсене", + "label.realtime": "В реално време", + "label.referral": "Реферал", + "label.referrer": "Референт", + "label.referrers": "Референти", + "label.refresh": "Обнови", + "label.regenerate": "Регенерирай", + "label.region": "Регион", + "label.regions": "Региони", + "label.remaining": "Оставащи", + "label.remove": "Премахни", + "label.remove-member": "Премахни член", + "label.reports": "Отчети", + "label.required": "Задължително", + "label.reset": "Нулирай", + "label.reset-website": "Нулирай уебсайт", + "label.retention": "Привързване", + "label.retention-description": "Измерете привързаността към вашия уебсайт, като проследявате колко често потребителите се връщат.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "Роля", + "label.run-query": "Изпълни запитване", + "label.save": "Запази", + "label.screens": "Екрани", + "label.search": "Търсене", + "label.select": "Избери", + "label.select-date": "Избери дата", + "label.select-filter": "Избери филтър", + "label.select-role": "Избери роля", + "label.select-website": "Избери уебсайт", + "label.session": "Сесия", + "label.session-data": "Данни за сесия", + "label.sessions": "Сесии", + "label.settings": "Настройки", + "label.share": "Сподели", + "label.share-url": "Сподели Линк", + "label.single-day": "Един ден", + "label.sms": "SMS", + "label.sources": "Източници", + "label.start-step": "Начална стъпка", + "label.steps": "Стъпки", + "label.sum": "Сума", + "label.tablet": "Таблет", + "label.tag": "Етикет", + "label.tags": "Етикети", + "label.team": "Екип", + "label.team-id": "ID на екип", + "label.team-manager": "Мениджър на екип", + "label.team-member": "Член на екипа", + "label.team-name": "Име на екипа", + "label.team-owner": "Собственик на екипа", + "label.team-settings": "Настройки на екипа", + "label.team-view-only": "Видимо само за членове на екипа", + "label.team-websites": "Уебсайтове на екипа", + "label.teams": "Екипи", + "label.terms": "Термини", + "label.theme": "Тема", + "label.this-month": "Този месец", + "label.this-week": "Тази седмица", + "label.this-year": "Тази година", + "label.timezone": "Часова зона", + "label.title": "Заглавие", + "label.today": "Днес", + "label.toggle-charts": "Виж диаграми", + "label.total": "Общо", + "label.total-records": "Общо записи", + "label.tracking-code": "Код за проследяване", + "label.transactions": "Transactions", + "label.transfer": "Прехвърли", + "label.transfer-website": "Прехвърляне на уебсайт", + "label.true": "Вярно", + "label.type": "Вид", + "label.unique": "Уникален", + "label.unique-visitors": "Уникални посетители", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Неизвестен", + "label.untitled": "Без заглавие", + "label.update": "Актуализирай", + "label.user": "Потребител", + "label.username": "Потребителско име", + "label.users": "Потребители", + "label.utm": "UTM", + "label.utm-description": "Следете кампаниите си чрез UTM параметри.", + "label.value": "Стойност", + "label.view": "Преглед", + "label.view-details": "Преглед на детайлите", + "label.view-only": "Само за преглед", + "label.views": "Прегледи", + "label.views-per-visit": "Прегледи на посещение", + "label.visit-duration": "Visit duration", + "label.visitors": "Посетители", + "label.visits": "Посещения", + "label.website": "Уебсайт", + "label.website-id": "Идентификатор на уебсайт", + "label.websites": "Уебсайтове", + "label.window": "Прозорец", + "label.yesterday": "Вчера", + "message.action-confirmation": "Въведете {confirmation} в полето по-долу, за да потвърдите.", + "message.active-users": "{x} {x, plural, one {активен един} other {активни други}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Сигурни ли сте, че искате да изтриете {target}?", + "message.confirm-leave": "Сигурни ли сте, че искате да напуснете {target}?", + "message.confirm-remove": "Сигурни ли сте, че искате да премахнете {target}?", + "message.confirm-reset": "Сигурни ли сте, че искате да нулирате {target}?", + "message.delete-team-warning": "Изтриването на екип ще изтрие и всички уебсайтове създадени от екипа.", + "message.delete-website-warning": "Всички данни за уебсайта ще бъдат изтрити.", + "message.error": "Възникна грешка.", + "message.event-log": "{event} на {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Отидете в настройките", + "message.incorrect-username-password": "Неправилно потребителско име и/или парола.", + "message.invalid-domain": "Невалиден домейн. Не включвайте http/https.", + "message.min-password-length": "Минимална дължина от {n} символа", + "message.new-version-available": "Има нова версия на Umami {version}!", + "message.no-data-available": "Няма налични данни.", + "message.no-event-data": "Няма налични данни за събитие.", + "message.no-match-password": "Паролите не съвпадат.", + "message.no-results-found": "Няма намерени резултати.", + "message.no-team-websites": "Този екип няма никакви уебсайтове.", + "message.no-teams": "Няма създадени екипи.", + "message.no-users": "Няма потребители.", + "message.no-websites-configured": "Нямате конфигурирани уебсайтове.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Страницата не е намерена", + "message.reset-website": "За да нулирате този уебсайт, въведете {confirmation} в полето по-долу, за да потвърдите.", + "message.reset-website-warning": "Всички статистически данни за този уебсайт ще бъдат изтрити, но вашите настройки ще останат непроменени.", + "message.saved": "Запазено.", + "message.sever-error": "Server error", + "message.share-url": "Статистиката за вашия уебсайт е публично достъпна на следния URL адрес:", + "message.team-already-member": "Вече сте член на екипа.", + "message.team-not-found": "Екипът не е намерен.", + "message.team-websites-info": "Уебсайтовете могат да бъдат преглеждани от всеки член на екипа.", + "message.tracking-code": "За активирате проследяването на статистиката във вашият уебсайт, поставете следния код в секцията <head>...</head> намираща се в вашия HTML.", + "message.transfer-team-website-to-user": "Искате да прехвърлите този уебсайт към вашия акаунт?", + "message.transfer-user-website-to-team": "Изберете екипът на който да бъде прехвърлен уебсайта.", + "message.transfer-website": "Прехвърли собствеността на уебсайта към вашия акаунт или към друг екип.", + "message.triggered-event": "Активирано събитие", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Потребителят е изтрит.", + "message.viewed-page": "Страницата е видяна", + "message.visitor-log": "Посетител от {country}, използващ {browser} на {os} {device}" +} diff --git a/src/lang/bn-BD.json b/src/lang/bn-BD.json new file mode 100644 index 0000000..9b9ad2f --- /dev/null +++ b/src/lang/bn-BD.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "এক্সেস কোড", + "label.actions": "অ্যাকশনস", + "label.activity": "একটিভিটি দেখুন", + "label.add": "যুক্ত করুন", + "label.add-board": "বোর্ড যুক্ত করুন", + "label.add-description": "বর্ননা যোগ করুন", + "label.add-member": "সদস্য যোগ করুন", + "label.add-step": "পদ যোগ করুন", + "label.add-website": "ওয়েবসাইট যুক্ত করুন", + "label.admin": "অ্যাডমিন", + "label.affiliate": "সহযোগী", + "label.after": "পরে", + "label.all": "সবগুলো", + "label.all-time": "সব সময়", + "label.analytics": "বিশ্লেষণ", + "label.apply": "প্রয়োগ করুন", + "label.attribution": "অ্যাট্রিবিউশন", + "label.attribution-description": "দেখুন ব্যবহারকারীরা কীভাবে আপনার মার্কেটিংয়ের সাথে যুক্ত হয় এবং কীভাবে রূপান্তর ঘটে।", + "label.average": "গড়", + "label.back": "পেছনে", + "label.before": "পূর্বে", + "label.behavior": "আচরণ", + "label.boards": "বোর্ডসমূহ", + "label.bounce-rate": "উপরে উঠার হার", + "label.breakdown": "ভাঙ্গন", + "label.browser": "ব্রাউজার", + "label.browsers": "ব্রাউজার সমূহ", + "label.campaigns": "প্রচারণা", + "label.cancel": "বাতিল", + "label.change-password": "পাসওয়ার্ড পরিবর্তন করুন", + "label.channels": "চ্যানেলসমূহ", + "label.cities": "শহরসমূহ", + "label.city": "শহর", + "label.clear-all": "সব মুছে ফেলুন", + "label.cohort": "কোহর্ট", + "label.compare": "তুলনা করুন", + "label.compare-dates": "তারিখ তুলনা করুন", + "label.confirm": "নিশ্চিত করুন", + "label.confirm-password": "পাসওয়ার্ড নিশ্চিত করুন", + "label.contains": "রয়েছে", + "label.content": "বিষয়বস্তু", + "label.continue": "পরবর্তিতে", + "label.conversion": "রূপান্তর", + "label.conversion-rate": "রূপান্তর হার", + "label.conversion-step": "রূপান্তর ধাপ", + "label.count": "গণনা", + "label.countries": "দেশসমূহ", + "label.country": "দেশ", + "label.create": "তৈরি করুন", + "label.create-report": "রিপোর্ট তৈরি করুন", + "label.create-team": "দল তৈরি করুন", + "label.create-user": "ব্যবহারকারী তৈরি করুন", + "label.created": "তৈরি করা হয়েছে", + "label.created-by": "তৈরি করেছেন", + "label.currency": "মুদ্রা", + "label.current": "বর্তমান", + "label.current-password": "বর্তমান পাসওয়ার্ড", + "label.custom-range": "কাস্টম রেঞ্জ", + "label.dashboard": "ড্যাশবোর্ড", + "label.data": "ডেটা", + "label.date": "তারিখ", + "label.date-range": "তারিখের পরিসীমা", + "label.day": "দিন", + "label.default-date-range": "ডিফল্ট তারিখের পরিসীমা", + "label.delete": "মুছে ফেলুন", + "label.delete-report": "রিপোর্ট মুছুন", + "label.delete-team": "দল মুছুন", + "label.delete-user": "ব্যবহারকারী মুছুন", + "label.delete-website": "ওয়েবসাইট মুছুন", + "label.description": "বর্ণনা", + "label.desktop": "ডেস্কটপ", + "label.details": "বিস্তারিত", + "label.device": "ডিভাইস", + "label.devices": "ডিভাইস গুলো", + "label.direct": "সরাসরি", + "label.dismiss": "বাতিল", + "label.distinct-id": "স্বতন্ত্র আইডি", + "label.does-not-contain": "ধারণ করে না", + "label.does-not-include": "অন্তর্ভুক্ত নয়", + "label.doest-not-exist": "অস্তিত্ব নেই", + "label.domain": "ডোমেইন", + "label.dropoff": "ছেড়ে যাওয়া", + "label.edit": "সম্পাদনা করুন", + "label.edit-dashboard": "ড্যাশবোর্ড সম্পাদনা করুন", + "label.edit-member": "সদস্য সম্পাদনা করুন", + "label.email": "Email", + "label.enable-share-url": "শেয়ার ইউআরএল শেয়ার করুন", + "label.end-step": "শেষ ধাপ", + "label.entry": "প্রবেশ URL", + "label.event": "ইভেন্ট", + "label.event-data": "ইভেন্ট ডেটা", + "label.event-name": "ইভেন্টের নাম", + "label.events": "ঘটনা", + "label.exists": "অস্তিত্ব আছে", + "label.exit": "প্রস্থান URL", + "label.false": "মিথ্যা", + "label.field": "ক্ষেত্র", + "label.fields": "ক্ষেত্রসমূহ", + "label.filter": "ফিল্টার", + "label.filter-combined": "সম্মিলিত", + "label.filter-raw": "অপরিশোধিত", + "label.filters": "ফিল্টারসমূহ", + "label.first-click": "প্রথম ক্লিক", + "label.first-seen": "প্রথম দেখা", + "label.funnel": "ফানেল", + "label.funnel-description": "ব্যবহারকারীদের রূপান্তর ও ছেড়ে যাওয়ার হার বুঝুন।", + "label.funnels": "ফানেলসমূহ", + "label.goal": "লক্ষ্য", + "label.goals": "লক্ষ্যসমূহ", + "label.goals-description": "পৃষ্ঠাদর্শন ও ইভেন্টের লক্ষ্য ট্র্যাক করুন।", + "label.greater-than": "এর চেয়ে বেশি", + "label.greater-than-equals": "এর চেয়ে বেশি বা সমান", + "label.grouped": "গ্রুপ করা", + "label.hostname": "হোস্টনেম", + "label.includes": "অন্তর্ভুক্ত", + "label.insight": "অন্তর্দৃষ্টি", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "হয়", + "label.is-false": "মিথ্যা", + "label.is-not": "নয়", + "label.is-not-set": "নির্ধারিত নয়", + "label.is-set": "নির্ধারিত", + "label.is-true": "সত্য", + "label.join": "যোগ দিন", + "label.join-team": "দলে যোগ দিন", + "label.journey": "যাত্রা", + "label.journey-description": "ব্যবহারকারীরা কীভাবে আপনার ওয়েবসাইটে নেভিগেট করে তা বুঝুন।", + "label.journeys": "যাত্রাসমূহ", + "label.language": "ভাষা", + "label.languages": "ভাষা", + "label.laptop": "ল্যাপটপ", + "label.last-click": "শেষ ক্লিক", + "label.last-days": "শেষ {x} দিন", + "label.last-hours": "শেষ {x} ঘন্টা", + "label.last-months": "শেষ {x} মাস", + "label.last-seen": "শেষ দেখা", + "label.leave": "ত্যাগ করুন", + "label.leave-team": "দল ত্যাগ করুন", + "label.less-than": "এর চেয়ে কম", + "label.less-than-equals": "এর চেয়ে কম বা সমান", + "label.links": "লিঙ্কসমূহ", + "label.login": "লগিন", + "label.logout": "লগ আউট", + "label.manage": "পরিচালনা করুন", + "label.manager": "পরিচালক", + "label.max": "সর্বাধিক", + "label.maximize": "বিস্তৃত করুন", + "label.medium": "মাঝারি", + "label.member": "সদস্য", + "label.members": "সদস্যগণ", + "label.min": "সর্বনিম্ন", + "label.mobile": "মুঠোফোন", + "label.model": "মডেল", + "label.more": "আরও", + "label.my-account": "আমার অ্যাকাউন্ট", + "label.my-websites": "আমার ওয়েবসাইটসমূহ", + "label.name": "নাম", + "label.new-password": "নতুন পাসওয়ার্ড", + "label.none": "কিছুই না", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "অর্গানিক সার্চ", + "label.organic-shopping": "অর্গানিক শপিং", + "label.organic-social": "অর্গানিক সোশ্যাল", + "label.organic-video": "অর্গানিক ভিডিও", + "label.os": "OS", + "label.other": "অন্যান্য", + "label.overview": "Overview", + "label.owner": "মালিক", + "label.page": "পৃষ্ঠা", + "label.page-of": "Page {current} of {total}", + "label.page-views": "পৃষ্ঠা পরিদর্শন গুলো", + "label.pageTitle": "Page title", + "label.pages": "পৃষ্ঠাগুলি", + "label.paid-ads": "পেইড বিজ্ঞাপন", + "label.paid-search": "পেইড সার্চ", + "label.paid-shopping": "পেইড শপিং", + "label.paid-social": "পেইড সোশ্যাল", + "label.paid-video": "পেইড ভিডিও", + "label.password": "পাসওয়ার্ড", + "label.path": "পথ", + "label.paths": "পথসমূহ", + "label.pixels": "পিক্সেল", + "label.powered-by": "{name} দ্বারা চালিত", + "label.previous": "পূর্ববর্তী", + "label.previous-period": "পূর্ববর্তী সময়কাল", + "label.previous-year": "গত বছর", + "label.profile": "প্রোফাইল", + "label.properties": "বৈশিষ্ট্যসমূহ", + "label.property": "বৈশিষ্ট্য", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "সরাসরি", + "label.referral": "রেফারেল", + "label.referrer": "Referrer", + "label.referrers": "রেফারার্স", + "label.refresh": "রিফ্রেশ", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "বাকি আছে", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "প্রয়োজনীয়", + "label.reset": "রিসেট", + "label.reset-website": "ওয়েবসাইট রিসেট করুন", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "আয়", + "label.revenue-description": "সময়ের সাথে সাথে আপনার আয় দেখুন।", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "সংরক্ষণ", + "label.screens": "স্ক্রিনগুলি", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "ফিল্টার নির্বাচন করুন", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "সেশন", + "label.session-data": "সেশন ডেটা", + "label.sessions": "Sessions", + "label.settings": "সেটিংস", + "label.share": "শেয়ার করুন", + "label.share-url": "এটি {target} এর জন্য প্রকাশ্যে শেয়ার করার ইউআরএল।", + "label.single-day": "একদিন", + "label.sms": "SMS", + "label.sources": "উৎসসমূহ", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "ট্যাবলেট", + "label.tag": "ট্যাগ", + "label.tags": "ট্যাগসমূহ", + "label.team": "দল", + "label.team-id": "দল আইডি", + "label.team-manager": "দল ব্যবস্থাপক", + "label.team-member": "দলের সদস্য", + "label.team-name": "দলের নাম", + "label.team-owner": "দলের মালিক", + "label.team-settings": "দলের সেটিংস", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "শর্তাবলী", + "label.theme": "থিম", + "label.this-month": "এই মাস", + "label.this-week": "এই সপ্তাহ", + "label.this-year": "এই বছর", + "label.timezone": "সময়স্থান", + "label.title": "Title", + "label.today": "আজ", + "label.toggle-charts": "চার্ট পরিবর্তন করুন", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "ট্র্যাকিং কোড", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "অনন্য ভিজিটর", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "অজানা", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "ব্যবহারকারীর নাম", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "বিস্তারিত দেখুন", + "label.view-only": "View only", + "label.views": "ভিউস", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "গড় পরিদর্শনের সময়", + "label.visitors": "পরিদর্শনার্থী", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "সবগুলো ওয়েবসাইট", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} বর্তমান {x, plural, one {visitor} other {visitors}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "আপনি কি নিশ্চিত যে আপনি {target} মুছতে চান?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "আপনি কি নিশ্চিত যে আপনি {target} এর পরিসংখ্যান পুনরায় সেট করতে চান?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "সমস্ত সম্পর্কিত ডেটা পাশাপাশি মুছে ফেলা হবে।", + "message.error": "কিছু ভুল হয়েছে।", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "সেটিংস এ যান", + "message.incorrect-username-password": "ভুল ব্যবহারকারীর নাম/পাসওয়ার্ড।", + "message.invalid-domain": "ভুল ডোমেন", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "কোন তথ্য নেই।", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "পাসওয়ার্ড মেলে না", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "কোনও ওয়েবসাইট কনফিগার করা নেই।", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "পৃষ্ঠা খুঁজে পাওয়া যায়নি।", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "এই ওয়েবসাইটের সমস্ত পরিসংখ্যান মুছে ফেলা হবে, তবে আপনার ট্র্যাকিং কোডটি অক্ষত থাকবে।", + "message.saved": "সংরক্ষিত হয়েছে।", + "message.sever-error": "Server error", + "message.share-url": "এটি {target} এর জন্য প্রকাশ্যে শেয়ার করার ইউআরএল।", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "ট্র্যাকিং কোড", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "{country} থেকে একজন ভিসিটর {ব্রাউজার}, ব্যবহার করছেন {os} {device} এর মধ্যে।" +} diff --git a/src/lang/bs-BA.json b/src/lang/bs-BA.json new file mode 100644 index 0000000..5684877 --- /dev/null +++ b/src/lang/bs-BA.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Pristupni kod", + "label.actions": "Akcije", + "label.activity": "Log aktivnosti", + "label.add": "Dodaj", + "label.add-board": "Dodaj ploču", + "label.add-description": "Dodaj opis", + "label.add-member": "Dodaj člana", + "label.add-step": "Dodaj korak", + "label.add-website": "Dodaj web stranicu", + "label.admin": "Administrator", + "label.affiliate": "Partner", + "label.after": "Nakon", + "label.all": "Sve", + "label.all-time": "Cijelo vrijeme", + "label.analytics": "Analitike", + "label.apply": "Primijeni", + "label.attribution": "Atribucija", + "label.attribution-description": "Pogledajte kako korisnici komuniciraju s vašim marketingom i šta dovodi do konverzija.", + "label.average": "Prosjek", + "label.back": "Nazad", + "label.before": "Prije", + "label.behavior": "Ponašanje", + "label.boards": "Ploče", + "label.bounce-rate": "Stopa napuštanja", + "label.breakdown": "Pregled po kategorijama", + "label.browser": "Browser", + "label.browsers": "Browseri", + "label.campaigns": "Kampanje", + "label.cancel": "Otkaži", + "label.change-password": "Promijeni šifru", + "label.channels": "Kanali", + "label.cities": "Gradovi", + "label.city": "Grad", + "label.clear-all": "Očisti sve", + "label.cohort": "Kohorta", + "label.compare": "Uporedi", + "label.compare-dates": "Uporedi datume", + "label.confirm": "Potvrdi", + "label.confirm-password": "Potvrdi šifru", + "label.contains": "Sadrži", + "label.content": "Sadržaj", + "label.continue": "Nastavi", + "label.conversion": "Konverzija", + "label.conversion-rate": "Stopa konverzije", + "label.conversion-step": "Korak konverzije", + "label.count": "Broj", + "label.countries": "Zemlje", + "label.country": "Zemlja", + "label.create": "Kreiraj", + "label.create-report": "Kreiraj izvještaj", + "label.create-team": "Kreiraj tim", + "label.create-user": "Kreiraj korisnika", + "label.created": "Kreiraj", + "label.created-by": "Kreirao", + "label.currency": "Valuta", + "label.current": "Trenutno", + "label.current-password": "Trenutna šifra", + "label.custom-range": "Proizvoljni raspon", + "label.dashboard": "Dashboard", + "label.data": "Podaci", + "label.date": "Datum", + "label.date-range": "Datumski raspon", + "label.day": "Dan", + "label.default-date-range": "Defaultni datumski raspon", + "label.delete": "Izbriši", + "label.delete-report": "Izbriši report", + "label.delete-team": "Izbriši tim", + "label.delete-user": "Izbriši korisnika", + "label.delete-website": "Izbriši web stranicu", + "label.description": "Opis", + "label.desktop": "Desktop", + "label.details": "Detalji", + "label.device": "Uređaj", + "label.devices": "Uređaji", + "label.direct": "Direktno", + "label.dismiss": "Odbaci", + "label.distinct-id": "Jedinstveni ID", + "label.does-not-contain": "Ne sadrži", + "label.does-not-include": "Ne uključuje", + "label.doest-not-exist": "Ne postoji", + "label.domain": "Domena", + "label.dropoff": "Odlazak", + "label.edit": "Uredi", + "label.edit-dashboard": "Uredi dashboard", + "label.edit-member": "Uredi člana", + "label.email": "E-mail", + "label.enable-share-url": "Omogući URL za dijeljenje", + "label.end-step": "Završni korak", + "label.entry": "URL ulaza", + "label.event": "Događaj", + "label.event-data": "Podaci o događaju", + "label.event-name": "Naziv događaja", + "label.events": "Događaji", + "label.exists": "Postoji", + "label.exit": "Exit URL", + "label.false": "Ne", + "label.field": "Polje", + "label.fields": "Polja", + "label.filter": "Filter", + "label.filter-combined": "Kombinovano", + "label.filter-raw": "Sirovo", + "label.filters": "Filtri", + "label.first-click": "Prvi klik", + "label.first-seen": "Prvi put viđeno", + "label.funnel": "Lijevak", + "label.funnel-description": "Razumite koverziju i drop-off učestalost korisnika.", + "label.funnels": "Lijevci", + "label.goal": "Cilj", + "label.goals": "Ciljevi", + "label.goals-description": "Pratite svoje ciljeve za prikaze stranica i događaje.", + "label.greater-than": "Veće od", + "label.greater-than-equals": "Veće od ili jednako", + "label.grouped": "Grupisano", + "label.hostname": "Naziv hosta", + "label.includes": "Uključuje", + "label.insight": "Uvid", + "label.insights": "Uvidi", + "label.insights-description": "Zaronite dublje u vaše podatke korištenjem segmenata i filtera", + "label.is": "Jeste", + "label.is-false": "Nije tačno", + "label.is-not": "Nije", + "label.is-not-set": "Nije setano", + "label.is-set": "Jeste setano", + "label.is-true": "Tačno", + "label.join": "Učlani se", + "label.join-team": "Učlani se u tim", + "label.journey": "Putovanje", + "label.journey-description": "Saznajte kako korisnici navigiraju vašom web stranicom.", + "label.journeys": "Putovanja", + "label.language": "Jezik", + "label.languages": "Jezici", + "label.laptop": "Laptop", + "label.last-click": "Zadnji klik", + "label.last-days": "Zadnjih {x} dana", + "label.last-hours": "Zadnjih {x} sati", + "label.last-months": "Zadnjih {x} mjeseci", + "label.last-seen": "Last seen", + "label.leave": "Napusti", + "label.leave-team": "Napusti tim", + "label.less-than": "Manje od", + "label.less-than-equals": "Manje od ili jednako", + "label.links": "Linkovi", + "label.login": "Login", + "label.logout": "Logout", + "label.manage": "Manage", + "label.manager": "Menadžer", + "label.max": "Max", + "label.maximize": "Proširi", + "label.medium": "Srednje", + "label.member": "Član", + "label.members": "Članovi", + "label.min": "Min", + "label.mobile": "Mobile", + "label.model": "Model", + "label.more": "Više", + "label.my-account": "Moj račun", + "label.my-websites": "Moje web stranice", + "label.name": "Ime", + "label.new-password": "Nova šifra", + "label.none": "Nijedno", + "label.number-of-records": "{x} {x, plural, one {zapis} other {zapisa}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organska pretraga", + "label.organic-shopping": "Organska kupovina", + "label.organic-social": "Organske društvene mreže", + "label.organic-video": "Organski video", + "label.os": "OS", + "label.other": "Drugo", + "label.overview": "Pregled", + "label.owner": "Vlasnik", + "label.page": "Stranica", + "label.page-of": "Strana {current} od {total}", + "label.page-views": "Pregleda stranica", + "label.pageTitle": "Naslov stranice", + "label.pages": "Stranice", + "label.paid-ads": "Plaćeni oglasi", + "label.paid-search": "Plaćena pretraga", + "label.paid-shopping": "Plaćena kupovina", + "label.paid-social": "Plaćene društvene mreže", + "label.paid-video": "Plaćeni video", + "label.password": "Šifra", + "label.path": "Putanja", + "label.paths": "Putanje", + "label.pixels": "Pikseli", + "label.powered-by": "Omogućeno s {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Profil", + "label.properties": "Svojstva", + "label.property": "Svojstvo", + "label.queries": "Upiti", + "label.query": "Upit", + "label.query-parameters": "Parametri upita", + "label.realtime": "Realno vrijeme", + "label.referral": "Preporuka", + "label.referrer": "Preporučilac", + "label.referrers": "Preporučioci", + "label.refresh": "Osvježi", + "label.regenerate": "Regeneriši", + "label.region": "Region", + "label.regions": "Regioni", + "label.remaining": "Preostalo", + "label.remove": "Ukloni", + "label.remove-member": "Ukloni člana", + "label.reports": "Izvještaji", + "label.required": "Obavezno", + "label.reset": "Resetuj", + "label.reset-website": "Resetuj web stranicu", + "label.retention": "Zadržavanje", + "label.retention-description": "Izmjeri 'ljepljivost' svoje web stranice praćenjem koliko često set korisnici vraćaju.", + "label.revenue": "Prihod", + "label.revenue-description": "Pogledajte svoje prihode tokom vremena.", + "label.role": "Rola", + "label.run-query": "Pokreni query", + "label.save": "Sačuvaj", + "label.screens": "Ekrani", + "label.search": "Traži", + "label.select": "Odaberi", + "label.select-date": "Odaberi datum", + "label.select-filter": "Odaberi filter", + "label.select-role": "Odaberi rolu", + "label.select-website": "Odaberi web stranicu", + "label.session": "Sesija", + "label.session-data": "Podaci o sesiji", + "label.sessions": "Sesije", + "label.settings": "Postavke", + "label.share": "Podijeli", + "label.share-url": "URL za dijeljenje", + "label.single-day": "Jedan dan", + "label.sms": "SMS", + "label.sources": "Izvori", + "label.start-step": "Početni korak", + "label.steps": "Koraci", + "label.sum": "Suma", + "label.tablet": "Tablet", + "label.tag": "Oznaka", + "label.tags": "Oznake", + "label.team": "Tim", + "label.team-id": "Tim ID", + "label.team-manager": "Menadžer tima", + "label.team-member": "Član tima", + "label.team-name": "Naziv tima", + "label.team-owner": "Vlasnik tima", + "label.team-settings": "Postavke tima", + "label.team-view-only": "Samo tim može vidjeti", + "label.team-websites": "Timske web stranice", + "label.teams": "Timovi", + "label.terms": "Pojmovi", + "label.theme": "Teme", + "label.this-month": "Ovaj mjesec", + "label.this-week": "Ova sedmica", + "label.this-year": "Ova godina", + "label.timezone": "Vremenska zona", + "label.title": "Naslov", + "label.today": "Danas", + "label.toggle-charts": "Uklj/isklj grafikone", + "label.total": "Ukupno", + "label.total-records": "Ukupno redova", + "label.tracking-code": "Kod za praćenje", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer web stranice", + "label.true": "Da", + "label.type": "Tip", + "label.unique": "Jedinstveno", + "label.unique-visitors": "Jedinstvenih posjetitelja", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Nepoznato", + "label.untitled": "Bezimeno", + "label.update": "Update", + "label.user": "Korisnik", + "label.username": "Korisničko ime", + "label.users": "Korisnici", + "label.utm": "UTM", + "label.utm-description": "Pratite vaše kampanje kroz UTM parametre.", + "label.value": "Vrijednost", + "label.view": "Pregled", + "label.view-details": "Pogledaj detalje", + "label.view-only": "Samo gledanje", + "label.views": "Pregledi", + "label.views-per-visit": "Pregledi po posjeti", + "label.visit-duration": "Prosječno vrijeme posjete", + "label.visitors": "Posjetitelji", + "label.visits": "Posjete", + "label.website": "Web stranica", + "label.website-id": "ID web stranice", + "label.websites": "Web stranice", + "label.window": "Prozor", + "label.yesterday": "Jučer", + "message.action-confirmation": "Unesite {confirmation} ispod da potvrdite.", + "message.active-users": "{x} trenutno {x, plural, one {posjetitelj} other {posjetitelja}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Jeste li sigurni da želite obrisati {target}?", + "message.confirm-leave": "Jeste li sigurni da želite napustiti {target}?", + "message.confirm-remove": "Jeste li sigurni da želite ukloniti {target}?", + "message.confirm-reset": "Jeste li sigurni da želite resetovati {target}?", + "message.delete-team-warning": "Brisanje tima će također obrisati sve web stranice tima.", + "message.delete-website-warning": "Svi podaci web stranice biće obrisani.", + "message.error": "Nešto je pošlo po zlu.", + "message.event-log": "{event} na {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Idi na postavke", + "message.incorrect-username-password": "Pogrešno korisničko ime i/ili šifra.", + "message.invalid-domain": "Nevalidna domena. Ne uključujte http/https.", + "message.min-password-length": "Minimalna dužina od {n} karaktera", + "message.new-version-available": "Nova verzija Umami {version} je dostupna!", + "message.no-data-available": "Nema dostupnih podataka.", + "message.no-event-data": "Nema dostupnih podataka o događajima.", + "message.no-match-password": "Šifre se ne poklapaju.", + "message.no-results-found": "Nema rezultata.", + "message.no-team-websites": "Ovaj tim nema nikakvih web stranica.", + "message.no-teams": "Niste kreirali nijedan tim.", + "message.no-users": "Nema nikakvih korisnika.", + "message.no-websites-configured": "Nemate iskonfigurisanu nijednu web stranicu.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Stranica nije pronađena", + "message.reset-website": "Da resetujete ovu web stranicu, upišite {confirmation} dole da potvrdite.", + "message.reset-website-warning": "Sve statistike o ovoj web stranici će biti obrisane, ali vaše postavke neće biti dirane.", + "message.saved": "Sačuvano.", + "message.sever-error": "Server error", + "message.share-url": "Statistike vaše web stranice su javno dostupne na sljedećem URLu:", + "message.team-already-member": "Već ste član tima.", + "message.team-not-found": "Tim nije pronađen.", + "message.team-websites-info": "Web stranice može vidjeti bilo ko iz tima.", + "message.tracking-code": "Da pratite statistike ove web stranice, stavite sljedeći kod u <head>...</head> sekciju vašeg HTMLa.", + "message.transfer-team-website-to-user": "Prebacite ovu web stranicu na vaš račun?", + "message.transfer-user-website-to-team": "Odaberite tim u koji želite prebaciti ovu web stranicu.", + "message.transfer-website": "Prebacite vlasništvo web stranice na vaš račun ili drugi tim.", + "message.triggered-event": "Trigerovani događaj", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Korisnik obrisan.", + "message.viewed-page": "Pogledana stranica", + "message.visitor-log": "Posjetitelj iz {country} koristi {browser} na {os} {device}" +} diff --git a/src/lang/ca-ES.json b/src/lang/ca-ES.json new file mode 100644 index 0000000..ab5444c --- /dev/null +++ b/src/lang/ca-ES.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Codi d'accés", + "label.actions": "Accions", + "label.activity": "Registre d'activitat", + "label.add": "Afegir", + "label.add-board": "Afegir tauler", + "label.add-description": "Afegir descripció", + "label.add-member": "Afegir membre", + "label.add-step": "Afegir pas", + "label.add-website": "Afegir lloc web", + "label.admin": "Administrador", + "label.affiliate": "Afiliat", + "label.after": "Després", + "label.all": "Tots", + "label.all-time": "Sempre", + "label.analytics": "Analítiques", + "label.apply": "Aplica", + "label.attribution": "Atribució", + "label.attribution-description": "Vegeu com els usuaris interactuen amb el vostre màrqueting i què impulsa les conversions.", + "label.average": "Mitjana", + "label.back": "Enrere", + "label.before": "Abans", + "label.behavior": "Comportament", + "label.boards": "Taulers", + "label.bounce-rate": "Percentatge de rebot", + "label.breakdown": "Desglossament", + "label.browser": "Navegador", + "label.browsers": "Navegadors", + "label.campaigns": "Campanyes", + "label.cancel": "Cancel·la", + "label.change-password": "Canvia la contrasenya", + "label.channels": "Canals", + "label.cities": "Ciutats", + "label.city": "Ciutat", + "label.clear-all": "Netejar tot", + "label.cohort": "Cohort", + "label.compare": "Comparar", + "label.compare-dates": "Comparar dates", + "label.confirm": "Confirmar", + "label.confirm-password": "Confirma la contrasenya", + "label.contains": "Conté", + "label.content": "Contingut", + "label.continue": "Continuar", + "label.conversion": "Conversió", + "label.conversion-rate": "Taxa de conversió", + "label.conversion-step": "Pas de conversió", + "label.count": "Recompte", + "label.countries": "Països", + "label.country": "País", + "label.create": "Crear", + "label.create-report": "Crear informe", + "label.create-team": "Crear equip", + "label.create-user": "Crear usuari", + "label.created": "Creat", + "label.created-by": "Creat Per", + "label.currency": "Moneda", + "label.current": "Actual", + "label.current-password": "Contrasenya actual", + "label.custom-range": "Rang personalitzat", + "label.dashboard": "Panell", + "label.data": "Dades", + "label.date": "Data", + "label.date-range": "Interval de dates", + "label.day": "Dia", + "label.default-date-range": "Interval de dates per defecte", + "label.delete": "Esborra", + "label.delete-report": "Eliminar informe", + "label.delete-team": "Eliminar equip", + "label.delete-user": "Eliminar usuari", + "label.delete-website": "Esborra el lloc web", + "label.description": "Descripció", + "label.desktop": "Escriptori", + "label.details": "Detalls", + "label.device": "Dispositiu", + "label.devices": "Dispositius", + "label.direct": "Directe", + "label.dismiss": "Descarta", + "label.distinct-id": "ID distintiu", + "label.does-not-contain": "No conté", + "label.does-not-include": "No inclou", + "label.doest-not-exist": "No existeix", + "label.domain": "Domini", + "label.dropoff": "Abandonament", + "label.edit": "Edita", + "label.edit-dashboard": "Edita panell", + "label.edit-member": "Edita membre", + "label.email": "Email", + "label.enable-share-url": "Activa l'enllaç per compartir", + "label.end-step": "Pas Final", + "label.entry": "URL d'entrada", + "label.event": "Esdeveniment", + "label.event-data": "Dades de l'esdeveniment", + "label.event-name": "Nom de l'esdeveniment", + "label.events": "Esdeveniments", + "label.exists": "Existeix", + "label.exit": "URL de sortida", + "label.false": "Fals", + "label.field": "Camp", + "label.fields": "Camps", + "label.filter": "Filtre", + "label.filter-combined": "Combinat", + "label.filter-raw": "En cru", + "label.filters": "Filtres", + "label.first-click": "Primer clic", + "label.first-seen": "Vist per primer cop", + "label.funnel": "Embut", + "label.funnel-description": "Entengui la taxa de conversió i abandonament dels usuaris.", + "label.funnels": "Embuts", + "label.goal": "Meta", + "label.goals": "Metes", + "label.goals-description": "Feu un seguiment de les seves metes per a pàgines vistes i esdeveniments.", + "label.greater-than": "Més gran que", + "label.greater-than-equals": "Més gran que o igual a", + "label.grouped": "Agrupat", + "label.hostname": "Nom de host", + "label.includes": "Inclou", + "label.insight": "Visió", + "label.insights": "Insights", + "label.insights-description": "Aprofundeixi en les seves dades mitjançant l'ús de segments i filtres.", + "label.is": "És igual a", + "label.is-false": "És fals", + "label.is-not": "No és igual a", + "label.is-not-set": "No està establert", + "label.is-set": "Està establert", + "label.is-true": "És cert", + "label.join": "Unir", + "label.join-team": "Unir-se al equip", + "label.journey": "Trajecte", + "label.journey-description": "Entengui com naveguen els usuaris pel seu lloc web.", + "label.journeys": "Trajectes", + "label.language": "Idioma", + "label.languages": "Idiomes", + "label.laptop": "Portàtil", + "label.last-click": "Últim clic", + "label.last-days": "Últims {x} dies", + "label.last-hours": "Últimes {x} hores", + "label.last-months": "Últims {x} mesos", + "label.last-seen": "Vist per últim cop", + "label.leave": "Abandonar", + "label.leave-team": "Abandonar equip", + "label.less-than": "Menor que", + "label.less-than-equals": "Menor que o igual a", + "label.links": "Enllaços", + "label.login": "Connecta't", + "label.logout": "Desconnecta't", + "label.manage": "Administrar", + "label.manager": "Responsable", + "label.max": "Màx", + "label.maximize": "Expandeix", + "label.medium": "Mitjà", + "label.member": "Membre", + "label.members": "Membres", + "label.min": "Mín", + "label.mobile": "Mòbil", + "label.model": "Model", + "label.more": "Més", + "label.my-account": "El meu compte", + "label.my-websites": "Els meus llocs web", + "label.name": "Nom", + "label.new-password": "Contrasenya nova", + "label.none": "Cap", + "label.number-of-records": "{x} {x, plural, one {registre} other {registres}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Cerca orgànica", + "label.organic-shopping": "Compra orgànica", + "label.organic-social": "Social orgànic", + "label.organic-video": "Vídeo orgànic", + "label.os": "SO", + "label.other": "Altres", + "label.overview": "Resum", + "label.owner": "Propietari", + "label.page": "Pàgina", + "label.page-of": "Pàgina {current} de {total}", + "label.page-views": "Pàgines vistes", + "label.pageTitle": "Títol de la pàgina", + "label.pages": "Pàgines", + "label.paid-ads": "Anuncis de pagament", + "label.paid-search": "Cerca de pagament", + "label.paid-shopping": "Compra de pagament", + "label.paid-social": "Social de pagament", + "label.paid-video": "Vídeo de pagament", + "label.password": "Contrasenya", + "label.path": "Camí", + "label.paths": "Camins", + "label.pixels": "Pixels", + "label.powered-by": "Funciona amb {name}", + "label.previous": "Anterior", + "label.previous-period": "Període anterior", + "label.previous-year": "Any anterior", + "label.profile": "Perfil", + "label.properties": "Propietats", + "label.property": "Propietat", + "label.queries": "Consultes", + "label.query": "Consulta", + "label.query-parameters": "Paràmetres de consulta", + "label.realtime": "Temps real", + "label.referral": "Referència", + "label.referrer": "Referent", + "label.referrers": "Referents", + "label.refresh": "Refresca", + "label.regenerate": "Regenerar", + "label.region": "Regió", + "label.regions": "Regions", + "label.remaining": "Restant", + "label.remove": "Treure", + "label.remove-member": "Eliminar membre", + "label.reports": "Informes", + "label.required": "Obligatori", + "label.reset": "Restableix", + "label.reset-website": "Restableix estadístiques", + "label.retention": "Retenció", + "label.retention-description": "Mesuri la retenció del seu lloc web fent un seguiment de la freqüència amb què tornen els usuaris.", + "label.revenue": "Ingressos", + "label.revenue-description": "Observi els seus ingressos al llarg del temps.", + "label.role": "Rol", + "label.run-query": "Executar consulta", + "label.save": "Desa", + "label.screens": "Pantalles", + "label.search": "Buscar", + "label.select": "Seleccionar", + "label.select-date": "Seleccionar data", + "label.select-filter": "Seleccionar filtre", + "label.select-role": "Seleccionar rol", + "label.select-website": "Seleccionar lloc web", + "label.session": "Sessió", + "label.session-data": "Dades de sessió", + "label.sessions": "Sessions", + "label.settings": "Configuració", + "label.share": "Comparteix", + "label.share-url": "Enllaç per compartir", + "label.single-day": "Un sol dia", + "label.sms": "SMS", + "label.sources": "Fonts", + "label.start-step": "Pas inicial", + "label.steps": "Pasos", + "label.sum": "Suma", + "label.tablet": "Tauleta", + "label.tag": "Etiqueta", + "label.tags": "Etiquetes", + "label.team": "Equip", + "label.team-id": "ID del equip", + "label.team-manager": "Responsable d'equip", + "label.team-member": "Membre de l'equip", + "label.team-name": "Nom de l'equip", + "label.team-owner": "Propietari de l'equip", + "label.team-settings": "Configuració de l'equip", + "label.team-view-only": "Vista només de l'equip", + "label.team-websites": "Llocs web de l'equip", + "label.teams": "Equips", + "label.terms": "Termes", + "label.theme": "Tema", + "label.this-month": "Aquest mes", + "label.this-week": "Aquesta setmana", + "label.this-year": "Aquest any", + "label.timezone": "Zona horària", + "label.title": "Títol", + "label.today": "Avui", + "label.toggle-charts": "Mostra/amaga gràfics", + "label.total": "Total", + "label.total-records": "Total de registres", + "label.tracking-code": "Codi de seguiment", + "label.transactions": "Transaccions", + "label.transfer": "Transferir", + "label.transfer-website": "Transferir lloc web", + "label.true": "Cert", + "label.type": "Tipus", + "label.unique": "Únic", + "label.unique-visitors": "Visitants únics", + "label.uniqueCustomers": "Clients Únics", + "label.unknown": "Desconegut", + "label.untitled": "Sense títol", + "label.update": "Actualitzar", + "label.user": "Usuari", + "label.username": "Nom d'usuari", + "label.users": "Usuaris", + "label.utm": "UTM", + "label.utm-description": "Rastreji les seves campanyes a través de paràmetres UTM.", + "label.value": "Valor", + "label.view": "Visualitzar", + "label.view-details": "Veure els detalls", + "label.view-only": "Només veure", + "label.views": "Vistes", + "label.views-per-visit": "Vistes per visita", + "label.visit-duration": "Temps mitjà de visita", + "label.visitors": "Visitants", + "label.visits": "Visites", + "label.website": "Lloc web", + "label.website-id": "ID del lloc web", + "label.websites": "Llocs web", + "label.window": "Finestra", + "label.yesterday": "Ahir", + "message.action-confirmation": "Escrigui {confirmation} al cuadre inferior per confirmar.", + "message.active-users": "{x} {x, plural, one {visitant actual} other {visitants actuals}}", + "message.bad-request": "Bad request", + "message.collected-data": "Dades recol·lectades", + "message.confirm-delete": "Segur que vol esborrar {target}?", + "message.confirm-leave": "Segur que vol abandonar {target}?", + "message.confirm-remove": "Segur que vol eliminar {target}?", + "message.confirm-reset": "Segur que vol restablir les estadístiques de {target}?", + "message.delete-team-warning": "Al eliminar un equip també s'eliminaran tots els llocs web de l'equip.", + "message.delete-website-warning": "També s'esborraran totes les dades relacionades.", + "message.error": "S'ha produït un error.", + "message.event-log": "{event} a {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Vés a la configuració", + "message.incorrect-username-password": "Nom d'usuari o contrasenya incorrectes.", + "message.invalid-domain": "Domini invàlid", + "message.min-password-length": "Longitud mínima de {n} caràcters", + "message.new-version-available": "Una nova versió d'Umami {version} està disponible!", + "message.no-data-available": "No hi ha dades disponibles.", + "message.no-event-data": "No hi ha dades d'esdeveniments disponibles.", + "message.no-match-password": "Les contrasenyes no coincideixen", + "message.no-results-found": "No s'han trobat resultats.", + "message.no-team-websites": "Aquest equip no té cap lloc web.", + "message.no-teams": "No ha creat cap equip.", + "message.no-users": "No hi ha cap usuari.", + "message.no-websites-configured": "No hi ha cap lloc web configurat.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "No s'ha trobat la pàgina.", + "message.reset-website": "Per restablir aquest lloc web, escrigui {confirmation} al cuadre inferior per confirmar.", + "message.reset-website-warning": "S'esborraran totes les estadístiques per aquest lloc web, però el codi de seguiment es mantindrà.", + "message.saved": "S'ha desat amb èxit.", + "message.sever-error": "Server error", + "message.share-url": "Aquest és l'enllaç públic per compartir de {target}.", + "message.team-already-member": "Ja és membre d'aquest equip.", + "message.team-not-found": "Equip no trobat.", + "message.team-websites-info": "Els llocs web poden ser visualitzats per qualsevol membre de l'equip.", + "message.tracking-code": "Codi de seguiment", + "message.transfer-team-website-to-user": "Transferir aquest lloc web al seu compte?", + "message.transfer-user-website-to-team": "Seleccioni l'equip al qui transferir aquest lloc web.", + "message.transfer-website": "Transferir la propietat del lloc web al seu compte o a un altre equip.", + "message.triggered-event": "Esdeveniment desencadenat", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Usuari eliminat.", + "message.viewed-page": "Pàgina vista", + "message.visitor-log": "Visitant de {country} usant {browser} a {os} {device}" +} diff --git a/src/lang/cs-CZ.json b/src/lang/cs-CZ.json new file mode 100644 index 0000000..77d45a7 --- /dev/null +++ b/src/lang/cs-CZ.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Přístupový kód", + "label.actions": "Akce", + "label.activity": "Log aktivity", + "label.add": "Přidat", + "label.add-board": "Přidat nástěnku", + "label.add-description": "Přidat popis", + "label.add-member": "Přidat člena", + "label.add-step": "Přidat krok", + "label.add-website": "Přidat web", + "label.admin": "Administrátor", + "label.affiliate": "Partner", + "label.after": "Po", + "label.all": "Vše", + "label.all-time": "Celá doba", + "label.analytics": "Analytika", + "label.apply": "Použít", + "label.attribution": "Atribuce", + "label.attribution-description": "Podívejte se, jak uživatelé interagují s vaším marketingem a co vede ke konverzím.", + "label.average": "Průměr", + "label.back": "Zpět", + "label.before": "Před", + "label.behavior": "Chování", + "label.boards": "Nástěnky", + "label.bounce-rate": "Okamžité opuštění", + "label.breakdown": "Rozpis", + "label.browser": "Prohlížeč", + "label.browsers": "Prohlížeče", + "label.campaigns": "Kampaně", + "label.cancel": "Zrušit", + "label.change-password": "Změnit heslo", + "label.channels": "Kanály", + "label.cities": "Města", + "label.city": "Město", + "label.clear-all": "Vyčistit vše", + "label.cohort": "Kohorta", + "label.compare": "Porovnat", + "label.compare-dates": "Porovnat data", + "label.confirm": "Potvrdit", + "label.confirm-password": "Potvrdit heslo", + "label.contains": "Obsahuje", + "label.content": "Obsah", + "label.continue": "Pokračovat", + "label.conversion": "Konverze", + "label.conversion-rate": "Míra konverze", + "label.conversion-step": "Krok konverze", + "label.count": "Počet", + "label.countries": "Státy", + "label.country": "Stát", + "label.create": "Vytvořit", + "label.create-report": "Vytvořit hlášení", + "label.create-team": "Vytvořit tým", + "label.create-user": "Vytvořit uživatele", + "label.created": "Vytvořeno", + "label.created-by": "Created By", + "label.currency": "Měna", + "label.current": "Aktuální", + "label.current-password": "Aktuální heslo", + "label.custom-range": "Vlastní rozsah", + "label.dashboard": "Přehled", + "label.data": "Data", + "label.date": "Datum", + "label.date-range": "Období", + "label.day": "Den", + "label.default-date-range": "Výchozí období", + "label.delete": "Smazat", + "label.delete-report": "Smazat hlášení", + "label.delete-team": "Smazat tým", + "label.delete-user": "Smazat uživatele", + "label.delete-website": "Smazat web", + "label.description": "Popis", + "label.desktop": "Stolní počítač", + "label.details": "Detaily", + "label.device": "Zařízení", + "label.devices": "Zařízení", + "label.direct": "Přímý", + "label.dismiss": "Odejít", + "label.distinct-id": "Jedinečné ID", + "label.does-not-contain": "Neobsahuje", + "label.does-not-include": "Nezahrnuje", + "label.doest-not-exist": "Neexistuje", + "label.domain": "Doména", + "label.dropoff": "Opuštění", + "label.edit": "Upravit", + "label.edit-dashboard": "Upravit dashboard", + "label.edit-member": "Upravit člena", + "label.email": "E-mail", + "label.enable-share-url": "Povolit sdílení URL", + "label.end-step": "Konečný krok", + "label.entry": "Vstupní URL", + "label.event": "Událost", + "label.event-data": "Data události", + "label.event-name": "Název události", + "label.events": "Události", + "label.exists": "Existuje", + "label.exit": "Exit URL", + "label.false": "Nepravda", + "label.field": "Pole", + "label.fields": "Pole", + "label.filter": "Filtr", + "label.filter-combined": "Kombinace", + "label.filter-raw": "Nezpracované", + "label.filters": "Filtry", + "label.first-click": "První kliknutí", + "label.first-seen": "Poprvé viděno", + "label.funnel": "Trychtýř", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Trychtýře", + "label.goal": "Cíl", + "label.goals": "Cíle", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "Větší než", + "label.greater-than-equals": "Větší nebo rovno", + "label.grouped": "Seskupeno", + "label.hostname": "Název hostitele", + "label.includes": "Zahrnuje", + "label.insight": "Pohled", + "label.insights": "Pohledy", + "label.insights-description": "Ponořte se hlouběji do svých dat pomocí segmentů a filtrů.", + "label.is": "Je", + "label.is-false": "Nepravda", + "label.is-not": "Není", + "label.is-not-set": "Není nastaveno", + "label.is-set": "Nastaveno", + "label.is-true": "Pravda", + "label.join": "Připojit se", + "label.join-team": "Připojit se k týmu", + "label.journey": "Cesta", + "label.journey-description": "Zjistěte, jak uživatelé procházejí vaším webem.", + "label.journeys": "Cesty", + "label.language": "Jazyk", + "label.languages": "Jazyky", + "label.laptop": "Přenosný počítač", + "label.last-click": "Poslední kliknutí", + "label.last-days": "Posledních {x} dnů", + "label.last-hours": "Posledních {x} hodin", + "label.last-months": "Posledních {x} měsíců", + "label.last-seen": "Last seen", + "label.leave": "Opustit", + "label.leave-team": "Opustit tým", + "label.less-than": "Méně než", + "label.less-than-equals": "Méně nebo rovno", + "label.links": "Odkazy", + "label.login": "Přihlásit", + "label.logout": "Odhlásit", + "label.manage": "Spravovat", + "label.manager": "Správce", + "label.max": "Max", + "label.maximize": "Rozbalit", + "label.medium": "Střední", + "label.member": "Člen", + "label.members": "Členové", + "label.min": "Min", + "label.mobile": "Mobilní telefon", + "label.model": "Model", + "label.more": "Více", + "label.my-account": "Můj účet", + "label.my-websites": "Mé weby", + "label.name": "Jméno", + "label.new-password": "Nové heslo", + "label.none": "Žádný", + "label.number-of-records": "{x} {x, plural, one {záznam} other {záznamů}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organické vyhledávání", + "label.organic-shopping": "Organický nákup", + "label.organic-social": "Organická sociální síť", + "label.organic-video": "Organické video", + "label.os": "OS", + "label.other": "Jiné", + "label.overview": "Přehled", + "label.owner": "Vlastník", + "label.page": "Stránka", + "label.page-of": "Stránka {current} z {total}", + "label.page-views": "Zobrazení stránek", + "label.pageTitle": "Název stránky", + "label.pages": "Stránky", + "label.paid-ads": "Placené reklamy", + "label.paid-search": "Placené vyhledávání", + "label.paid-shopping": "Placený nákup", + "label.paid-social": "Placená sociální síť", + "label.paid-video": "Placené video", + "label.password": "Heslo", + "label.path": "Cesta", + "label.paths": "Cesty", + "label.pixels": "Pixely", + "label.powered-by": "Běží na {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Profil", + "label.properties": "Vlastnosti", + "label.property": "Vlastnost", + "label.queries": "Dotazy", + "label.query": "Dotaz", + "label.query-parameters": "Parametry dotazu", + "label.realtime": "Aktuálně", + "label.referral": "Doporučení", + "label.referrer": "Odkazující", + "label.referrers": "Odkazující", + "label.refresh": "Obnovit", + "label.regenerate": "Regenerovat", + "label.region": "Region", + "label.regions": "Regiony", + "label.remaining": "Zbývá", + "label.remove": "Odstranit", + "label.remove-member": "Odstranit člena", + "label.reports": "Hlášení", + "label.required": "Povinné", + "label.reset": "Resetovat", + "label.reset-website": "Resetovat statistiky", + "label.retention": "Udržení", + "label.retention-description": "Měřte přilnavost svého webu sledováním, jak často se uživatelé vracejí.", + "label.revenue": "Příjem", + "label.revenue-description": "Podívejte se na své příjmy v průběhu času.", + "label.role": "Role", + "label.run-query": "Spustit dotaz", + "label.save": "Uložit", + "label.screens": "Obrazovky", + "label.search": "Hledat", + "label.select": "Vybrat", + "label.select-date": "Vybrat datum", + "label.select-filter": "Vybrat filtr", + "label.select-role": "Vybrat roli", + "label.select-website": "Vybrat web", + "label.session": "Relace", + "label.session-data": "Data relace", + "label.sessions": "Relace", + "label.settings": "Nastavení", + "label.share": "Sdílet", + "label.share-url": "URL pro sdílení", + "label.single-day": "Jeden den", + "label.sms": "SMS", + "label.sources": "Zdroje", + "label.start-step": "Počáteční krok", + "label.steps": "Kroky", + "label.sum": "Součet", + "label.tablet": "Tablet", + "label.tag": "Štítek", + "label.tags": "Štítky", + "label.team": "Tým", + "label.team-id": "ID týmu", + "label.team-manager": "Manažer týmu", + "label.team-member": "Člen týmu", + "label.team-name": "Název týmu", + "label.team-owner": "Vlastník týmu", + "label.team-settings": "Nastavení týmu", + "label.team-view-only": "Pouze pro zobrazení týmu", + "label.team-websites": "Weby týmu", + "label.teams": "Týmy", + "label.terms": "Termíny", + "label.theme": "Téma", + "label.this-month": "Tento měsíc", + "label.this-week": "Tento týden", + "label.this-year": "Tento rok", + "label.timezone": "Časová zóna", + "label.title": "Title", + "label.today": "Dnes", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Sledovací kód", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Jedinečné návštěvy", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Neznámý", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Uživatelské jméno", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Zobrazit detaily", + "label.view-only": "View only", + "label.views": "Zobrazení", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Průměrný čas návštěvy", + "label.visitors": "Návštěvníci", + "label.visits": "Návštěvy", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Weby", + "label.window": "Okno", + "label.yesterday": "Včera", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} aktuálně {x, plural, one {návštěvník} other {návštěvníci}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Opravdu smazat {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "Všechna související data budou také smazána.", + "message.error": "Něco se pokazilo.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Jít do nastavení", + "message.incorrect-username-password": "Nesprávné jméno/heslo.", + "message.invalid-domain": "Neplatná doména", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Žádná data.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Hesla se neschodují", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "Nemáte nastavený žádný web.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Stránka nenalezena.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.", + "message.saved": "Úspěšně uloženo.", + "message.sever-error": "Server error", + "message.share-url": "Toto je sdílené URL pro {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Sledovací kód", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Návštěvník z {country} s prohlížečem {browser} na {os} {device}" +} diff --git a/src/lang/da-DK.json b/src/lang/da-DK.json new file mode 100644 index 0000000..f6c447f --- /dev/null +++ b/src/lang/da-DK.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Adgangskode", + "label.actions": "Handlinger", + "label.activity": "Aktivitetslog", + "label.add": "Tilføj", + "label.add-board": "Tilføj tavle", + "label.add-description": "Tilføj beskrivelse", + "label.add-member": "Tilføj medlem", + "label.add-step": "Tilføj trin", + "label.add-website": "Tilføj hjemmeside", + "label.admin": "Administrator", + "label.affiliate": "Partner", + "label.after": "Efter", + "label.all": "Alle", + "label.all-time": "Altid", + "label.analytics": "Analyser", + "label.apply": "Anvend", + "label.attribution": "Attribuering", + "label.attribution-description": "Se, hvordan brugere interagerer med din markedsføring, og hvad der driver konverteringer.", + "label.average": "Gennemsnit", + "label.back": "Tilbage", + "label.before": "Før", + "label.behavior": "Adfærd", + "label.boards": "Tavler", + "label.bounce-rate": "Afvisningsprocent", + "label.breakdown": "Opdeling", + "label.browser": "Browser", + "label.browsers": "Browsere", + "label.campaigns": "Kampagner", + "label.cancel": "Afvis", + "label.change-password": "Skift adgangskode", + "label.channels": "Kanaler", + "label.cities": "Byer", + "label.city": "By", + "label.clear-all": "Ryd alt", + "label.cohort": "Kohorte", + "label.compare": "Sammenlign", + "label.compare-dates": "Sammenlign datoer", + "label.confirm": "Bekræft", + "label.confirm-password": "Godkendt adgangskode", + "label.contains": "Contains", + "label.content": "Indhold", + "label.continue": "Fortsæt", + "label.conversion": "Konvertering", + "label.conversion-rate": "Konverteringsrate", + "label.conversion-step": "Konverteringstrin", + "label.count": "Antal", + "label.countries": "Lande", + "label.country": "Land", + "label.create": "Opret", + "label.create-report": "Opret rapport", + "label.create-team": "Opret team", + "label.create-user": "Opret bruger", + "label.created": "Oprettet", + "label.created-by": "Oprettet af", + "label.currency": "Valuta", + "label.current": "Nuværende", + "label.current-password": "Nuværende adgangskode", + "label.custom-range": "Tilpasset interval", + "label.dashboard": "Betjeningspanel", + "label.data": "Data", + "label.date": "Dato", + "label.date-range": "Datointerval", + "label.day": "Dag", + "label.default-date-range": "Standard datointerval", + "label.delete": "Slet", + "label.delete-report": "Slet rapport", + "label.delete-team": "Slet team", + "label.delete-user": "Slet bruger", + "label.delete-website": "Slet hjemmeside", + "label.description": "Beskrivelse", + "label.desktop": "Skrivebord", + "label.details": "Detaljer", + "label.device": "Enhed", + "label.devices": "Enheder", + "label.direct": "Direkte", + "label.dismiss": "Afvis", + "label.distinct-id": "Unikt ID", + "label.does-not-contain": "Indeholder ikke", + "label.does-not-include": "Inkluderer ikke", + "label.doest-not-exist": "Findes ikke", + "label.domain": "Domæne", + "label.dropoff": "Frafald", + "label.edit": "Rediger", + "label.edit-dashboard": "Rediger betjeningspanel", + "label.edit-member": "Rediger medlem", + "label.email": "E-mail", + "label.enable-share-url": "Aktivér delings-URL", + "label.end-step": "Sluttrin", + "label.entry": "Indgangs-URL", + "label.event": "Hændelse", + "label.event-data": "Hændelsesdata", + "label.event-name": "Hændelsesnavn", + "label.events": "Hændelser", + "label.exists": "Findes", + "label.exit": "Udgangs-URL", + "label.false": "Falsk", + "label.field": "Felt", + "label.fields": "Felter", + "label.filter": "Filter", + "label.filter-combined": "Kombineret", + "label.filter-raw": "Rå", + "label.filters": "Filtre", + "label.first-click": "Første klik", + "label.first-seen": "Først set", + "label.funnel": "Tragt", + "label.funnel-description": "Forstå brugernes konverterings- og frafaldsrate.", + "label.funnels": "Tragte", + "label.goal": "Mål", + "label.goals": "Mål", + "label.goals-description": "Følg dine mål for sidevisninger og hændelser.", + "label.greater-than": "Større end", + "label.greater-than-equals": "Større end eller lig med", + "label.grouped": "Gruperet", + "label.hostname": "Værtsnavn", + "label.includes": "Inkluderer", + "label.insight": "Indsigt", + "label.insights": "Indsigter", + "label.insights-description": "Dyk dybere ned i dine data ved at bruge segmenter og filtre.", + "label.is": "Er", + "label.is-false": "Er falsk", + "label.is-not": "Er ikke", + "label.is-not-set": "Er ikke sat", + "label.is-set": "Er sat", + "label.is-true": "Er sandt", + "label.join": "Deltag", + "label.join-team": "Deltag i team", + "label.journey": "Rejse", + "label.journey-description": "Forstå hvordan brugere navigerer på din hjemmeside.", + "label.journeys": "Rejser", + "label.language": "Sprog", + "label.languages": "Sprog", + "label.laptop": "Laptop", + "label.last-click": "Sidste klik", + "label.last-days": "Sidste {x} dage", + "label.last-hours": "Sidste {x} timer", + "label.last-months": "Sidste {x} måneder", + "label.last-seen": "Sidst set", + "label.leave": "Forlad", + "label.leave-team": "Forlad team", + "label.less-than": "Mindre end", + "label.less-than-equals": "Mindre end eller lig med", + "label.links": "Links", + "label.login": "Log ind", + "label.logout": "Log ud", + "label.manage": "Administrer", + "label.manager": "Leder", + "label.max": "Maks", + "label.maximize": "Udvid", + "label.medium": "Medium", + "label.member": "Medlem", + "label.members": "Medlemmer", + "label.min": "Min", + "label.mobile": "Mobil", + "label.model": "Model", + "label.more": "Mere", + "label.my-account": "Min konto", + "label.my-websites": "Mine hjemmesider", + "label.name": "Navn", + "label.new-password": "Ny adgangskode", + "label.none": "Ingen", + "label.number-of-records": "{x} {x, plural, one {post} other {poster}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organisk søgning", + "label.organic-shopping": "Organisk shopping", + "label.organic-social": "Organisk social", + "label.organic-video": "Organisk video", + "label.os": "OS", + "label.other": "Andet", + "label.overview": "Oversigt", + "label.owner": "Ejer", + "label.page": "Side", + "label.page-of": "Side {current} af {total}", + "label.page-views": "Sidevisninger", + "label.pageTitle": "Sidetitel", + "label.pages": "Sider", + "label.paid-ads": "Betalte annoncer", + "label.paid-search": "Betalt søgning", + "label.paid-shopping": "Betalt shopping", + "label.paid-social": "Betalt social", + "label.paid-video": "Betalt video", + "label.password": "Adgangskode", + "label.path": "Sti", + "label.paths": "Stier", + "label.pixels": "Pixels", + "label.powered-by": "Drevet af {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Profil", + "label.properties": "Egenskaber", + "label.property": "Egenskab", + "label.queries": "Forespørgsler", + "label.query": "Forespørgsel", + "label.query-parameters": "Forespørgselsparametre", + "label.realtime": "Realtid", + "label.referral": "Henvisning", + "label.referrer": "Henviser", + "label.referrers": "Henvisninger", + "label.refresh": "Opdater", + "label.regenerate": "Gendan", + "label.region": "Region", + "label.regions": "Regioner", + "label.remaining": "Tilbageværende", + "label.remove": "Fjern", + "label.remove-member": "Fjern medlem", + "label.reports": "Rapporter", + "label.required": "Påkrævet", + "label.reset": "Nulstil", + "label.reset-website": "Nulstil statistik", + "label.retention": "Fastholdelse", + "label.retention-description": "Mål hvor ofte brugere vender tilbage til din hjemmeside.", + "label.revenue": "Indtægt", + "label.revenue-description": "Se din indtægt over tid.", + "label.role": "Rolle", + "label.run-query": "Kør forespørgsel", + "label.save": "Gem", + "label.screens": "Skærme", + "label.search": "Søg", + "label.select": "Vælg", + "label.select-date": "Vælg dato", + "label.select-filter": "Vælg filter", + "label.select-role": "Vælg rolle", + "label.select-website": "Vælg hjemmeside", + "label.session": "Session", + "label.session-data": "Sessionsdata", + "label.sessions": "Sessioner", + "label.settings": "Indstillinger", + "label.share": "Del", + "label.share-url": "Del URL", + "label.single-day": "Enkelt dag", + "label.sms": "SMS", + "label.sources": "Kilder", + "label.start-step": "Starttrin", + "label.steps": "Trin", + "label.sum": "Sum", + "label.tablet": "Tablet", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Teamleder", + "label.team-member": "Teammedlem", + "label.team-name": "Teamnavn", + "label.team-owner": "Teamejer", + "label.team-settings": "Teamindstillinger", + "label.team-view-only": "Kun visning af team", + "label.team-websites": "Teamets hjemmesider", + "label.teams": "Teams", + "label.terms": "Vilkår", + "label.theme": "Tema", + "label.this-month": "Denne måned", + "label.this-week": "Denne uge", + "label.this-year": "Dette år", + "label.timezone": "Tidszone", + "label.title": "Title", + "label.today": "Idag", + "label.toggle-charts": "Ændre graf", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Sporingskode", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Unikke besøgende", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Ukendt", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Brugernavn", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Vis detajler", + "label.view-only": "View only", + "label.views": "Visninger", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Gennemsnitlig besøgstid", + "label.visitors": "Besøgende", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Hjemmesider", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} nuværende {x, plural, one {bruger} other {brugere}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Er du sikker på at du vil slette {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Er du sikker på at du ville nulstille {target}'s statistikker?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "Alle tilknyttede data slettes også.", + "message.error": "Noget gik galt.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Gå til betjeningspanel", + "message.incorrect-username-password": "Ugyldigt brugernavn/adgangskode.", + "message.invalid-domain": "Ugyldigt domæne", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Ingen data tilgængelig.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Adgangskoderne matcher ikke", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "Du har ikke konfigureret nogen hjemmesider.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Side ikke fundet.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "Alle statistikker for denne hjemmeside ville blive slettet, men sporingskode ville forblive intakt.", + "message.saved": "Gemt!", + "message.sever-error": "Server error", + "message.share-url": "Dette er den offentlige delings-URL til {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Sporingskode", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Besøgende fra {country} bruger {browser} på {os} {device}" +} diff --git a/src/lang/de-CH.json b/src/lang/de-CH.json new file mode 100644 index 0000000..55734eb --- /dev/null +++ b/src/lang/de-CH.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Zuegangscode", + "label.actions": "Aktione", + "label.activity": "Aktivitätsverlauf", + "label.add": "hinzuefüege", + "label.add-board": "Board hinzuefüege", + "label.add-description": "Beschriibig hinzuefüege", + "label.add-member": "Mitglied hinzuefüege", + "label.add-step": "Schritt hinzuefüege", + "label.add-website": "Websiite hinzuefüege", + "label.admin": "Administrator", + "label.affiliate": "Partnerprogramm", + "label.after": "Nach", + "label.all": "Alli", + "label.all-time": "Gsamte Zitruum", + "label.analytics": "Analytik", + "label.apply": "Aawände", + "label.attribution": "Zuordnig", + "label.attribution-description": "Lueg wie d'Benutzer mit dim Marketing interagiere und was zu Umwandlige führt.", + "label.average": "Durchschnitt", + "label.back": "Zrugg", + "label.before": "Vor", + "label.behavior": "Verhalte", + "label.boards": "Boards", + "label.bounce-rate": "Absprungsrate", + "label.breakdown": "Uufschlüsselig", + "label.browser": "Browser", + "label.browsers": "Browser", + "label.campaigns": "Kampagne", + "label.cancel": "Abbreche", + "label.change-password": "Passwort ändere", + "label.channels": "Kanäle", + "label.cities": "Städt", + "label.city": "Stadt", + "label.clear-all": "Alles lösche", + "label.cohort": "Gruppe", + "label.compare": "Vergliiche", + "label.compare-dates": "Datum vergleiche", + "label.confirm": "Bestätige", + "label.confirm-password": "Passwort widerhole", + "label.contains": "Enthaltet", + "label.content": "Inhalt", + "label.continue": "Wiiter", + "label.conversion": "Umwandlig", + "label.conversion-rate": "Umwandligsrate", + "label.conversion-step": "Umwandligsschritt", + "label.count": "Azahl", + "label.countries": "Länder", + "label.country": "Land", + "label.create": "Erstelle", + "label.create-report": "Bricht erstelle", + "label.create-team": "Team erstelle", + "label.create-user": "Benutzer erstelle", + "label.created": "Erstellt", + "label.created-by": "Erstellt vo", + "label.currency": "Währung", + "label.current": "Aktuell", + "label.current-password": "Aktuells Passwort", + "label.custom-range": "Benutzerdefinierte Bereich", + "label.dashboard": "Übersicht", + "label.data": "Datä", + "label.date": "Datum", + "label.date-range": "Datumsbereich", + "label.day": "Tag", + "label.default-date-range": "Voriigstellte Datumsbereich", + "label.delete": "Lösche", + "label.delete-report": "Bricht lösche", + "label.delete-team": "Team lösche", + "label.delete-user": "Benutzer lösche", + "label.delete-website": "Websiite lösche", + "label.description": "Beschriibig", + "label.desktop": "Desktop", + "label.details": "Details", + "label.device": "Grät", + "label.devices": "Grät", + "label.direct": "Direkt", + "label.dismiss": "Verwärfe", + "label.distinct-id": "Eindeutigi ID", + "label.does-not-contain": "Enthaltet nid", + "label.does-not-include": "Isch nid debii", + "label.doest-not-exist": "Existiert nid", + "label.domain": "Domain", + "label.dropoff": "Absprung", + "label.edit": "Bearbeite", + "label.edit-dashboard": "Dashboard bearbeite", + "label.edit-member": "Mitglied bearbeite", + "label.email": "Email", + "label.enable-share-url": "Freigab-URL aktiviere", + "label.end-step": "Schlussschritt", + "label.entry": "Iigangs URL", + "label.event": "Ereigniss", + "label.event-data": "Ereigniss Date", + "label.event-name": "Ereignissname", + "label.events": "Ereigniss", + "label.exists": "Existiert", + "label.exit": "Uusgangs URL", + "label.false": "Falsch", + "label.field": "Fäld", + "label.fields": "Fälder", + "label.filter": "Filter", + "label.filter-combined": "Kombiniert", + "label.filter-raw": "Rohdate", + "label.filters": "Filters", + "label.first-click": "Erste Klick", + "label.first-seen": "Erstmal gse", + "label.funnel": "Tunnel", + "label.funnel-description": "Verstönd Sie d Konversions- und Abspruungsrate vo Nutzer.", + "label.funnels": "Funnels", + "label.goal": "Ziel", + "label.goals": "Ziele", + "label.goals-description": "verfolged Sie Ihri Ziel für Siitenufrüef und Ereigniss.", + "label.greater-than": "Grösser als", + "label.greater-than-equals": "Grösser oder gliich", + "label.grouped": "Gruppiert", + "label.hostname": "Hostnam", + "label.includes": "Isch debii", + "label.insight": "Iiblick", + "label.insights": "Iiblick", + "label.insights-description": "Vertüfed Sie sich i Ihri Date, mit Hilf vo Segment und Filter.", + "label.is": "Isch", + "label.is-false": "Isch falsch", + "label.is-not": "Isch nid", + "label.is-not-set": "Isch ned gsetzt", + "label.is-set": "Isch gsetzt", + "label.is-true": "Isch wahr", + "label.join": "Biträte", + "label.join-team": "Team biträte", + "label.journey": "Reis", + "label.journey-description": "Verstönd Sie, wie Nutzer dur Ihri Website navigiered.", + "label.journeys": "Reise", + "label.language": "Sprach", + "label.languages": "Sprache", + "label.laptop": "Laptop", + "label.last-click": "Letzte Klick", + "label.last-days": "Letzti {x} Täg", + "label.last-hours": "Letzti {x} Stunde", + "label.last-months": "Letzti {x} Mönet", + "label.last-seen": "Zletzt gse", + "label.leave": "Verlah", + "label.leave-team": "Team verlah", + "label.less-than": "Kliiner als", + "label.less-than-equals": "Kliiner oder gliich", + "label.links": "Links", + "label.login": "Aamälde", + "label.logout": "Abmälde", + "label.manage": "Verwalte", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Uusklappe", + "label.medium": "Medium", + "label.member": "Mitglied", + "label.members": "Mitglieder", + "label.min": "Min", + "label.mobile": "Händy", + "label.model": "Model", + "label.more": "Meh", + "label.my-account": "Min Account", + "label.my-websites": "Mini Websiite", + "label.name": "Name", + "label.new-password": "Neus Passwort", + "label.none": "Keis", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organischi Suechi", + "label.organic-shopping": "Organischi Iikauf", + "label.organic-social": "Organischi Social Media", + "label.organic-video": "Organischi Video", + "label.os": "OS", + "label.other": "Anderi", + "label.overview": "Übersicht", + "label.owner": "Bsitzer", + "label.page": "Siite", + "label.page-of": "Siite {current} vo {total}", + "label.page-views": "Siitenufrüef", + "label.pageTitle": "Siitetitel", + "label.pages": "Siite", + "label.paid-ads": "Bezahlti Werbung", + "label.paid-search": "Bezahlti Suechi", + "label.paid-shopping": "Bezahlti Iikauf", + "label.paid-social": "Bezahlti Social Media", + "label.paid-video": "Bezahlti Video", + "label.password": "Passwort", + "label.path": "Pfad", + "label.paths": "Pfade", + "label.pixels": "Pixel", + "label.powered-by": "Betriibe dur {name}", + "label.previous": "Vorherig", + "label.previous-period": "Vorherigi Periode", + "label.previous-year": "Vorherigs Jahr", + "label.profile": "Profil", + "label.properties": "Eigeschafte", + "label.property": "Eigeschafte", + "label.queries": "Abfrage", + "label.query": "Abfrag", + "label.query-parameters": "Abfragparameter", + "label.realtime": "Echtzit", + "label.referral": "Empfehlig", + "label.referrer": "Verwiiser", + "label.referrers": "Verwiisendi", + "label.refresh": "Aktualisiere", + "label.regenerate": "Erneuere", + "label.region": "Region", + "label.regions": "Regionä", + "label.remaining": "Verblibe", + "label.remove": "Entferne", + "label.remove-member": "Mitglied entferne", + "label.reports": "Brichte", + "label.required": "Erforderlich", + "label.reset": "Zruggsetze", + "label.reset-website": "Statistik zruggsetze", + "label.retention": "Retention", + "label.retention-description": "Mässed Sie d Verwiilduur vo Ihrere Website, indem Sie verfolged wie oft ihri Nutzer zruggkehred.", + "label.revenue": "Umsatz", + "label.revenue-description": "Lueged Sie sich Ihre Umsatz im Lauf vor Ziit a.", + "label.role": "Rollä", + "label.run-query": "Abfrag starte", + "label.save": "Speichere", + "label.screens": "Bildschirmuflösige", + "label.search": "Sueche", + "label.select": "Auswähle", + "label.select-date": "Datä uuswähle", + "label.select-filter": "Filter uuswähle", + "label.select-role": "Rollä uuswähle", + "label.select-website": "Websiite uuswähle", + "label.session": "Sitzig", + "label.session-data": "Sitzigsdate", + "label.sessions": "Sitzige", + "label.settings": "Istellige", + "label.share": "Teile", + "label.share-url": "Freigab-URL", + "label.single-day": "Ein Tag", + "label.sms": "SMS", + "label.sources": "Quälle", + "label.start-step": "Startschritt", + "label.steps": "Schritt", + "label.sum": "Summe", + "label.tablet": "Tablet", + "label.tag": "Tag", + "label.tags": "Stichwort", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team Manager", + "label.team-member": "Team Mitglied", + "label.team-name": "Team Name", + "label.team-owner": "Team Bsitzer", + "label.team-settings": "Team Istellige", + "label.team-view-only": "Nur für Teammitglieder sichtbar", + "label.team-websites": "Team Websiite", + "label.teams": "Teams", + "label.terms": "Bedingige", + "label.theme": "Thema", + "label.this-month": "Dä Monet", + "label.this-week": "Diä Wuuche", + "label.this-year": "Das Johr", + "label.timezone": "Ziitzone", + "label.title": "Titel", + "label.today": "Hüt", + "label.toggle-charts": "Charts umschalte", + "label.total": "Total", + "label.total-records": "Gsamti Datesätz", + "label.tracking-code": "Tracking Code", + "label.transactions": "Transaktione", + "label.transfer": "Transferiere", + "label.transfer-website": "Websiite transferiere", + "label.true": "Wahr", + "label.type": "Typ", + "label.unique": "Einzigartigi", + "label.unique-visitors": "Einzigartigi Bsuecher", + "label.uniqueCustomers": "Einzigartigi Kunde", + "label.unknown": "Unbekannt", + "label.untitled": "Unbennant", + "label.update": "Update", + "label.user": "Benutzer", + "label.username": "Benutzername", + "label.users": "Benutzer", + "label.utm": "UTM", + "label.utm-description": "Tracked Sie Ihri Kampagnen mit UTM Parameters.", + "label.value": "Wärt", + "label.view": "Azeige", + "label.view-details": "Details azeige", + "label.view-only": "Nume aluege", + "label.views": "Ufrüef", + "label.views-per-visit": "Ufrüef pro Bsuech", + "label.visit-duration": "Durchschn. Bsuechsziit", + "label.visitors": "Bsuecher", + "label.visits": "Bsüech", + "label.website": "Website", + "label.website-id": "Websiite ID", + "label.websites": "Websiite", + "label.window": "Fenster", + "label.yesterday": "Gester", + "message.action-confirmation": "Typed Sie {confirmation} is Feld underhalb um z bestätige.", + "message.active-users": "{x} {x, plural, one {aktive Bsuecher} other {aktivi Bsuecher}}", + "message.bad-request": "Bad request", + "message.collected-data": "Gsammleti Date", + "message.confirm-delete": "Sind Sie sich sicher, {target} zlösche?", + "message.confirm-leave": "Sind Sie sich sicher, {target} zverlah?", + "message.confirm-remove": "Sind Sie sich sicher, dass Sie {target} wänd entferne?", + "message.confirm-reset": "Sind Sie sicher, dass Sie d Statistike vo {target} zruggsetze wänd?", + "message.delete-team-warning": "Es Team lösche dued ebefalls alli team Websiite lösche.", + "message.delete-website-warning": "Alli dezueghörige Date werded ebefalls glöscht.", + "message.error": "Es isch en Fehler ufträte.", + "message.event-log": "{event} uf {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Zu de Istellige", + "message.incorrect-username-password": "Falsches Passwort oder Benutzername.", + "message.invalid-domain": "Ungültigi Domain", + "message.min-password-length": "Miminamli längi vo {n} Zeiche", + "message.new-version-available": "Es isch en neue Version vo Umami {version} verfügbar!", + "message.no-data-available": "Kei Date vorhande.", + "message.no-event-data": "Es sind kei Event Date verfügbar.", + "message.no-match-password": "Passwörter stimmed ned überi", + "message.no-results-found": "Kei Ergäbnis gfunde.", + "message.no-team-websites": "Dem Team sind kei Websiite zuegordnet.", + "message.no-teams": "Bisher sind no kei Teams erstellt worde.", + "message.no-users": "Da gits kei Benutzer", + "message.no-websites-configured": "Es isch kei Websiite vorhande.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Siite ned gfunde.", + "message.reset-website": "Um die Websiite zruggzsetze, typed Sie {confirmation} is Feld unde dran.", + "message.reset-website-warning": "Alli Date für die Websiite werdet glöscht, nur de Tracking Code blibt bestah.", + "message.saved": "Erfolgrich gspeichert.", + "message.sever-error": "Server error", + "message.share-url": "Ihri Websiitestatistik isch under de folgende URL öffentlich zuegänglich:", + "message.team-already-member": "Sie sind bereits es Mitglied vo däm Team.", + "message.team-not-found": "Team nöd gfunde.", + "message.team-websites-info": "Websiite chöi vo jedem im Team agluegt werde", + "message.tracking-code": "Tracking Code", + "message.transfer-team-website-to-user": "Websiite uf zu Ihrem Account transferiere?", + "message.transfer-user-website-to-team": "Wähled Sie s Team zum däm Websiite transferiert werde söll.", + "message.transfer-website": "Übertraged Sie d Websiite Eigetümerrecht uf Ihre Account oder uf es anders Team", + "message.triggered-event": "Usglösts Ereigniss", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Bnutzer glöscht.", + "message.viewed-page": "Siite agluegt", + "message.visitor-log": "Bsuecher us {country} nutzt {browser} uf {os} {device}" +} diff --git a/src/lang/de-DE.json b/src/lang/de-DE.json new file mode 100644 index 0000000..3436eb8 --- /dev/null +++ b/src/lang/de-DE.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Zugangscode", + "label.actions": "Aktionen", + "label.activity": "Aktivitätsverlauf", + "label.add": "Hinzufügen", + "label.add-board": "Board hinzufügen", + "label.add-description": "Beschreibung hinzufügen", + "label.add-member": "Mitglied hinzufügen", + "label.add-step": "Schritt hinzufügen", + "label.add-website": "Website hinzufügen", + "label.admin": "Administrator", + "label.affiliate": "Partnerprogramm", + "label.after": "Nach", + "label.all": "Alle", + "label.all-time": "Gesamter Zeitraum", + "label.analytics": "Analysen", + "label.apply": "Anwenden", + "label.attribution": "Zuordnung", + "label.attribution-description": "Sehen Sie, wie Nutzer mit Ihrem Marketing interagieren und was zu Konversionen führt.", + "label.average": "Durchschnitt", + "label.back": "Zurück", + "label.before": "Vor", + "label.behavior": "Verhalten", + "label.boards": "Boards", + "label.bounce-rate": "Absprungrate", + "label.breakdown": "Aufschlüsselung", + "label.browser": "Browser", + "label.browsers": "Browser", + "label.campaigns": "Kampagnen", + "label.cancel": "Abbrechen", + "label.change-password": "Passwort ändern", + "label.channels": "Kanäle", + "label.cities": "Städte", + "label.city": "Stadt", + "label.clear-all": "Alles löschen", + "label.cohort": "Gruppe", + "label.compare": "Vergleichen", + "label.compare-dates": "Daten vergleichen", + "label.confirm": "Bestätigen", + "label.confirm-password": "Passwort wiederholen", + "label.contains": "Enthält", + "label.content": "Inhalt", + "label.continue": "Weiter", + "label.conversion": "Konversion", + "label.conversion-rate": "Konversionsrate", + "label.conversion-step": "Konversionsschritt", + "label.count": "Anzahl", + "label.countries": "Länder", + "label.country": "Land", + "label.create": "Erstellen", + "label.create-report": "Bericht erstellen", + "label.create-team": "Team erstellen", + "label.create-user": "Benutzer erstellen", + "label.created": "Erstellt", + "label.created-by": "Erstellt von", + "label.currency": "Währung", + "label.current": "Aktuell", + "label.current-password": "Derzeitiges Passwort", + "label.custom-range": "Benutzerdefinierter Bereich", + "label.dashboard": "Übersicht", + "label.data": "Daten", + "label.date": "Datum", + "label.date-range": "Datumsbereich", + "label.day": "Tag", + "label.default-date-range": "Voreingestellter Datumsbereich", + "label.delete": "Löschen", + "label.delete-report": "Bericht löschen", + "label.delete-team": "Team löschen", + "label.delete-user": "Benutzer löschen", + "label.delete-website": "Website löschen", + "label.description": "Beschreibung", + "label.desktop": "Desktop", + "label.details": "Details", + "label.device": "Gerät", + "label.devices": "Geräte", + "label.direct": "Direkt", + "label.dismiss": "Verwerfen", + "label.distinct-id": "Eindeutige ID", + "label.does-not-contain": "Enthält nicht", + "label.does-not-include": "Nicht enthalten", + "label.doest-not-exist": "Existiert nicht", + "label.domain": "Domain", + "label.dropoff": "Absprung", + "label.edit": "Bearbeiten", + "label.edit-dashboard": "Dashboard bearbeiten", + "label.edit-member": "Mitglied bearbeiten", + "label.email": "Email", + "label.enable-share-url": "Freigabe-URL aktivieren", + "label.end-step": "Schlussschritt", + "label.entry": "Eingangs-URL", + "label.event": "Ereignis", + "label.event-data": "Ereignisdaten", + "label.event-name": "Ereignisname", + "label.events": "Ereignisse", + "label.exists": "Existiert", + "label.exit": "Ausgangs-URL", + "label.false": "Falsch", + "label.field": "Feld", + "label.fields": "Felder", + "label.filter": "Filter", + "label.filter-combined": "Kombiniert", + "label.filter-raw": "Rohdaten", + "label.filters": "Filter", + "label.first-click": "Erster Klick", + "label.first-seen": "Erstmalig gesehen", + "label.funnel": "Trichter", + "label.funnel-description": "Verstehen Sie die Konversions- und Absprungrate Ihrer Nutzer.", + "label.funnels": "Funnels", + "label.goal": "Ziel", + "label.goals": "Ziele", + "label.goals-description": "Verfolgen Sie Ihre Ziele für Seitenaufrufe und Ereignisse.", + "label.greater-than": "Größer als", + "label.greater-than-equals": "Größer oder gleich", + "label.grouped": "Gruppiert", + "label.hostname": "Hostname", + "label.includes": "Enthält", + "label.insight": "Einblick", + "label.insights": "Einblicke", + "label.insights-description": "Vertiefen Sie sich mit Hilfe von Segmenten und Filtern in Ihre Daten.", + "label.is": "Ist", + "label.is-false": "Ist falsch", + "label.is-not": "Ist nicht", + "label.is-not-set": "Ist nicht gesetzt", + "label.is-set": "Ist gesetzt", + "label.is-true": "Ist wahr", + "label.join": "Beitreten", + "label.join-team": "Team beitreten", + "label.journey": "Reise", + "label.journey-description": "Verstehen Sie, wie Nutzer auf Ihrer Website navigieren.", + "label.journeys": "Reisen", + "label.language": "Sprache", + "label.languages": "Sprachen", + "label.laptop": "Laptop", + "label.last-click": "Letzter Klick", + "label.last-days": "Letzten {x} Tage", + "label.last-hours": "Letzten {x} Stunden", + "label.last-months": "Letzten {x} Monate", + "label.last-seen": "Zuletzt gesehen", + "label.leave": "Verlassen", + "label.leave-team": "Team verlassen", + "label.less-than": "Kleiner als", + "label.less-than-equals": "Kleiner oder gleich", + "label.links": "Links", + "label.login": "Anmelden", + "label.logout": "Abmelden", + "label.manage": "Verwalten", + "label.manager": "Verwaltung", + "label.max": "Max", + "label.maximize": "Erweitern", + "label.medium": "Medium", + "label.member": "Mitglied", + "label.members": "Mitglieder", + "label.min": "Min", + "label.mobile": "Handy", + "label.model": "Model", + "label.more": "Mehr", + "label.my-account": "Mein Account", + "label.my-websites": "Meine Websites", + "label.name": "Name", + "label.new-password": "Neues Passwort", + "label.none": "Keine", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organische Suche", + "label.organic-shopping": "Organisches Shopping", + "label.organic-social": "Organisches Social Media", + "label.organic-video": "Organisches Video", + "label.os": "OS", + "label.other": "Andere", + "label.overview": "Übersicht", + "label.owner": "Besitzer", + "label.page": "Seite", + "label.page-of": "Seite {current} von {total}", + "label.page-views": "Seitenaufrufe", + "label.pageTitle": "Seitentitel", + "label.pages": "Seiten", + "label.paid-ads": "Bezahlte Anzeigen", + "label.paid-search": "Bezahlte Suche", + "label.paid-shopping": "Bezahltes Shopping", + "label.paid-social": "Bezahltes Social Media", + "label.paid-video": "Bezahltes Video", + "label.password": "Passwort", + "label.path": "Pfad", + "label.paths": "Pfade", + "label.pixels": "Pixel", + "label.powered-by": "Betrieben durch {name}", + "label.previous": "Vorherig", + "label.previous-period": "Vorherige Periode", + "label.previous-year": "Vorheriges Jahr", + "label.profile": "Profil", + "label.properties": "Eigenschaften", + "label.property": "Eigenschaft", + "label.queries": "Abfragen", + "label.query": "Abfrage", + "label.query-parameters": "Abfrageparameter", + "label.realtime": "Echtzeit", + "label.referral": "Empfehlung", + "label.referrer": "Übermittler", + "label.referrers": "Übermittler", + "label.refresh": "Aktualisieren", + "label.regenerate": "Erneuern", + "label.region": "Region", + "label.regions": "Regionen", + "label.remaining": "Verbleibend", + "label.remove": "Entfernen", + "label.remove-member": "Mitglied entfernen", + "label.reports": "Berichte", + "label.required": "Erforderlich", + "label.reset": "Zurücksetzen", + "label.reset-website": "Statistik zurücksetzen", + "label.retention": "Erhalt", + "label.retention-description": "Messen Sie die Verweildauer auf Ihrer Website, indem Sie verfolgen, wie oft die Nutzer zurückkehren.", + "label.revenue": "Umsatz", + "label.revenue-description": "Haben Sie einen Blick auf Ihre Umsätze im Laufe der Zeit.", + "label.role": "Rolle", + "label.run-query": "Abfrage starten", + "label.save": "Speichern", + "label.screens": "Bildschirmauflösungen", + "label.search": "Suche", + "label.select": "Auswählen", + "label.select-date": "Datum auswählen", + "label.select-filter": "Filter auswählen", + "label.select-role": "Rolle auswählen", + "label.select-website": "Website auswählen", + "label.session": "Sitzung", + "label.session-data": "Sitzungsdaten", + "label.sessions": "Sitzungen", + "label.settings": "Einstellungen", + "label.share": "Teilen", + "label.share-url": "Freigabe-URL", + "label.single-day": "Ein Tag", + "label.sms": "SMS", + "label.sources": "Quellen", + "label.start-step": "Startschritt", + "label.steps": "Schritte", + "label.sum": "Summe", + "label.tablet": "Tablet", + "label.tag": "Tag", + "label.tags": "Stichworte", + "label.team": "Team", + "label.team-id": "Team-ID", + "label.team-manager": "Team-Manager", + "label.team-member": "Team-Mitglied", + "label.team-name": "Name des Teams", + "label.team-owner": "Team-Eigentümer", + "label.team-settings": "Team-Einstellungen", + "label.team-view-only": "Nur für Team-Mitglieder sichtbar", + "label.team-websites": "Team-Websites", + "label.teams": "Teams", + "label.terms": "Bedingungen", + "label.theme": "Thema", + "label.this-month": "Diesen Monat", + "label.this-week": "Diese Woche", + "label.this-year": "Dieses Jahr", + "label.timezone": "Zeitzone", + "label.title": "Titel", + "label.today": "Heute", + "label.toggle-charts": "Schaubilder umschalten", + "label.total": "Gesamt", + "label.total-records": "Datensätze insgesamt", + "label.tracking-code": "Tracking Code", + "label.transactions": "Transaktionen", + "label.transfer": "Übertragung", + "label.transfer-website": "Website übertragen", + "label.true": "Wahr", + "label.type": "Typ", + "label.unique": "Einzigartig", + "label.unique-visitors": "Einzigartige Besucher", + "label.uniqueCustomers": "Einzigartige Kunden", + "label.unknown": "Unbekannt", + "label.untitled": "Unbenannt", + "label.update": "Update", + "label.user": "Benutzer", + "label.username": "Benutzername", + "label.users": "Benutzer", + "label.utm": "UTM", + "label.utm-description": "Tracken Sie Ihre Kampagnen mit UTM Parametern.", + "label.value": "Wert", + "label.view": "Anzeigen", + "label.view-details": "Details anzeigen", + "label.view-only": "Nur ansehen", + "label.views": "Aufrufe", + "label.views-per-visit": "Aufrufe pro Besuch", + "label.visit-duration": "Durchschn. Besuchszeit", + "label.visitors": "Besucher", + "label.visits": "Besuche", + "label.website": "Website", + "label.website-id": "Website-ID", + "label.websites": "Websites", + "label.window": "Fenster", + "label.yesterday": "Gestern", + "message.action-confirmation": "Schreibe {confirmation} in die Box zur bestätigung.", + "message.active-users": "{x} {x, plural, one {aktiver Besucher} other {aktive Besucher}}", + "message.bad-request": "Bad request", + "message.collected-data": "Gesammelte Daten", + "message.confirm-delete": "Sind Sie sich sicher, {target} zu löschen?", + "message.confirm-leave": "Sind Sie sicher, dass die {target} verlassen möchten?", + "message.confirm-remove": "Sind Sie sicher, {target} zu entfernen?", + "message.confirm-reset": "Sind Sie sicher, dass Sie die Statistiken von {target} zurücksetzen wollen?", + "message.delete-team-warning": "Ein Team zu löschen, wird auch alle Team-Websites löschen.", + "message.delete-website-warning": "Alle zugehörigen Daten werden ebenfalls gelöscht.", + "message.error": "Es ist ein Fehler aufgetreten.", + "message.event-log": "{event} auf {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Zu den Einstellungen", + "message.incorrect-username-password": "Falsches Passwort oder Benutzername.", + "message.invalid-domain": "Ungültige Domain", + "message.min-password-length": "Minimale Länge von {n} Zeichen", + "message.new-version-available": "Eine neue Version von Umami ist verfügbar: {version}", + "message.no-data-available": "Keine Daten vorhanden.", + "message.no-event-data": "Es sind keine Ereignisdaten verfügbar.", + "message.no-match-password": "Passwörter stimmen nicht überein", + "message.no-results-found": "Keine Ergebnisse gefunden.", + "message.no-team-websites": "Diesem Team sind keine Websites zugeordnet.", + "message.no-teams": "Bisher wurden keine Teams erstellt.", + "message.no-users": "Hier gibt es keine Benutzer.", + "message.no-websites-configured": "Es ist keine Website vorhanden.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Seite nicht gefunden.", + "message.reset-website": "Um diese Website zurückzusetzen, geben Sie zur Bestätigung {confirmation} in das Feld unten ein.", + "message.reset-website-warning": "Alle Daten für diese Website werden gelöscht, jedoch bleibt der Tracking Code bestehen.", + "message.saved": "Erfolgreich gespeichert.", + "message.sever-error": "Server error", + "message.share-url": "Die Statistiken Ihrer Website sind unter folgender URL öffentlich zugänglich:", + "message.team-already-member": "Sie sind bereits Mitglied des Teams.", + "message.team-not-found": "Team nicht gefunden.", + "message.team-websites-info": "Websites können von jedem im Team eingesehen werden.", + "message.tracking-code": "Tracking Code", + "message.transfer-team-website-to-user": "Diese Website zu Ihrem Account transferieren?", + "message.transfer-user-website-to-team": "Wählen Sie ein Team aus, zu dem die Website transferiert werden soll.", + "message.transfer-website": "Übertragen Sie die Eigentümerrechte zu Ihrem Account oder einem anderen Team.", + "message.triggered-event": "Ereignis ausgelöst", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Benutzer gelöscht.", + "message.viewed-page": "Seite besucht", + "message.visitor-log": "Besucher aus {country} benutzt {browser} auf {os} {device}" +} diff --git a/src/lang/el-GR.json b/src/lang/el-GR.json new file mode 100644 index 0000000..720ff5e --- /dev/null +++ b/src/lang/el-GR.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Access code", + "label.actions": "Ενέργειες", + "label.activity": "Activity log", + "label.add": "Add", + "label.add-board": "Add board", + "label.add-description": "Add description", + "label.add-member": "Add member", + "label.add-step": "Add step", + "label.add-website": "Προσθήκη ιστότοπου", + "label.admin": "Διαχειριστής", + "label.affiliate": "Affiliate", + "label.after": "After", + "label.all": "All", + "label.all-time": "All time", + "label.analytics": "Analytics", + "label.apply": "Apply", + "label.attribution": "Attribution", + "label.attribution-description": "See how users engage with your marketing and what drives conversions.", + "label.average": "Average", + "label.back": "Πίσω", + "label.before": "Before", + "label.boards": "Boards", + "label.bounce-rate": "Ποσοστό αναπήδησης", + "label.breakdown": "Breakdown", + "label.behavior": "Συμπεριφορά", + "label.browser": "Browser", + "label.browsers": "Προγράμματα περιήγησης", + "label.campaigns": "Campaigns", + "label.cancel": "Ακύρωση", + "label.change-password": "Αλλαγή κωδικού", + "label.channels": "Channels", + "label.cities": "Cities", + "label.city": "City", + "label.clear-all": "Clear all", + "label.cohort": "Cohort", + "label.compare": "Compare", + "label.compare-dates": "Compare dates", + "label.confirm": "Confirm", + "label.confirm-password": "Επιβεβαίωση κωδικού", + "label.contains": "Contains", + "label.content": "Content", + "label.continue": "Continue", + "label.conversion": "Conversion", + "label.conversion-rate": "Conversion rate", + "label.conversion-step": "Conversion step", + "label.count": "Count", + "label.countries": "Χώρες", + "label.country": "Country", + "label.create": "Create", + "label.create-report": "Create report", + "label.create-team": "Create team", + "label.create-user": "Create user", + "label.created": "Created", + "label.created-by": "Created By", + "label.currency": "Currency", + "label.current": "Current", + "label.current-password": "Τωρινός κωδικός πρόσβασης", + "label.custom-range": "Προσαρμοσμένο εύρος", + "label.dashboard": "Πίνακας", + "label.data": "Data", + "label.date": "Date", + "label.date-range": "Εύρος ημερομηνιών", + "label.day": "Day", + "label.default-date-range": "Προεπιλεγμένο εύρος ημερομηνιών", + "label.delete": "Διαγραφή", + "label.delete-report": "Delete report", + "label.delete-team": "Delete team", + "label.delete-user": "Delete user", + "label.delete-website": "Διαγραφή ιστότοπου", + "label.description": "Description", + "label.desktop": "Σταθερός υπολογιστής", + "label.details": "Details", + "label.device": "Device", + "label.devices": "Συσκευές", + "label.direct": "Direct", + "label.dismiss": "Dismiss", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "Does not contain", + "label.does-not-include": "Does not include", + "label.doest-not-exist": "Does not exist", + "label.domain": "Τομέας", + "label.dropoff": "Dropoff", + "label.edit": "Επεξεργασία", + "label.edit-dashboard": "Edit dashboard", + "label.edit-member": "Edit member", + "label.email": "Email", + "label.enable-share-url": "Ενεργοποίηση κοινής χρήσης URL", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "Event", + "label.event-data": "Event data", + "label.event-name": "Event name", + "label.events": "Γεγονότα", + "label.exists": "Exists", + "label.exit": "Exit URL", + "label.false": "False", + "label.field": "Field", + "label.fields": "Fields", + "label.filter": "Filter", + "label.filter-combined": "Σε συνδυασμό", + "label.filter-raw": "Ακατέργαστο", + "label.filters": "Filters", + "label.first-click": "First click", + "label.first-seen": "First seen", + "label.funnel": "Funnel", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Funnels", + "label.goal": "Goal", + "label.goals": "Goals", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "Greater than", + "label.greater-than-equals": "Greater than or equals", + "label.grouped": "Grouped", + "label.hostname": "Hostname", + "label.includes": "Includes", + "label.insight": "Insight", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "Is", + "label.is-false": "Is false", + "label.is-not": "Is not", + "label.is-not-set": "Is not set", + "label.is-set": "Is set", + "label.is-true": "Is true", + "label.join": "Join", + "label.join-team": "Join team", + "label.journey": "Journey", + "label.journey-description": "Understand how users navigate through your website.", + "label.journeys": "Journeys", + "label.language": "Language", + "label.languages": "Languages", + "label.laptop": "Λάπτοπ", + "label.last-click": "Last click", + "label.last-days": "Τελευταίες {x} ημέρες", + "label.last-hours": "Τελευταίες {x} ώρες", + "label.last-months": "Last {x} months", + "label.last-seen": "Last seen", + "label.leave": "Leave", + "label.leave-team": "Leave team", + "label.less-than": "Less than", + "label.less-than-equals": "Less than or equals", + "label.links": "Links", + "label.login": "Είσοδος", + "label.logout": "Αποσύνδεση", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Expand", + "label.medium": "Medium", + "label.member": "Member", + "label.members": "Members", + "label.min": "Min", + "label.mobile": "Κινητό", + "label.model": "Model", + "label.more": "Περισσότερα", + "label.my-account": "My account", + "label.my-websites": "My websites", + "label.name": "Όνομα", + "label.new-password": "Νέος κωδικός", + "label.none": "None", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organic search", + "label.organic-shopping": "Organic shopping", + "label.organic-social": "Organic social", + "label.organic-video": "Organic video", + "label.os": "OS", + "label.other": "Other", + "label.overview": "Overview", + "label.owner": "Owner", + "label.page": "Page", + "label.page-of": "Page {current} of {total}", + "label.page-views": "Προβολές σελίδας", + "label.pageTitle": "Page title", + "label.pages": "Σελίδες", + "label.paid-ads": "Paid ads", + "label.paid-search": "Paid search", + "label.paid-shopping": "Paid shopping", + "label.paid-social": "Paid social", + "label.paid-video": "Paid video", + "label.password": "Κωδικός", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "Pixels", + "label.powered-by": "Με την υποστήριξη του {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Προφίλ", + "label.properties": "Properties", + "label.property": "Property", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "Realtime", + "label.referral": "Referral", + "label.referrer": "Referrer", + "label.referrers": "Παραπομπές", + "label.refresh": "Ανανέωση", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Remaining", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "Απαιτείται", + "label.reset": "Επαναφορά", + "label.reset-website": "Reset statistics", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Αποθήκευση", + "label.screens": "Screens", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Select filter", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Session", + "label.session-data": "Session data", + "label.sessions": "Sessions", + "label.settings": "Ρυθμίσεις", + "label.share": "Share", + "label.share-url": "Κοινοποίηση διεύθυνσης URL", + "label.single-day": "Ημερήσια", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "Τάμπλετ", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Team settings", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Terms", + "label.theme": "Theme", + "label.this-month": "Αυτο το μήνα", + "label.this-week": "Αυτή την εβδομάδα", + "label.this-year": "Αυτή την χρονιά", + "label.timezone": "Ζώνη ώρας", + "label.title": "Title", + "label.today": "Σήμερα", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Κωδικός παρακολούθησης", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Μοναδικοί επισκέπτες", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Άγνωστο", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Όνομα χρήστη", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Λεπτομέρειες", + "label.view-only": "View only", + "label.views": "Προβολές", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Μέσος χρόνος επίσκεψης", + "label.visitors": "Επισκέπτες", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Ιστότοποι", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} ενεργοί {x, plural, one {επισκέπτης} other {επισκέπτες}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το {target};", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "Όλα τα σχετικά δεδομένα θα διαγραφούν επίσης.", + "message.error": "Κάτι πήγε στραβά.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Μεταβείτε στις ρυθμίσεις", + "message.incorrect-username-password": "Εσφαλμένο όνομα χρήστη / κωδικός πρόσβασης.", + "message.invalid-domain": "Μη έγκυρος τομέας", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Δεν υπάρχουν διαθέσιμα δεδομένα.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Οι κωδικοί πρόσβασης δεν ταιριάζουν", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "Δεν έχετε ρυθμίσει κανένα ιστότοπο.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Η σελίδα δεν βρέθηκε.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.", + "message.saved": "Αποθηκεύτηκε επιτυχώς.", + "message.sever-error": "Server error", + "message.share-url": "Αυτό είναι το κοινόχρηστο URL για το {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Κωδικός παρακολούθησης", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Visitor from {country} using {browser} on {os} {device}" +} diff --git a/src/lang/en-GB.json b/src/lang/en-GB.json new file mode 100644 index 0000000..7803dd6 --- /dev/null +++ b/src/lang/en-GB.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Access code", + "label.actions": "Actions", + "label.activity": "Activity log", + "label.add": "Add", + "label.add-board": "Add board", + "label.add-description": "Add description", + "label.add-member": "Add member", + "label.add-step": "Add step", + "label.add-website": "Add website", + "label.admin": "Administrator", + "label.affiliate": "Affiliate", + "label.after": "After", + "label.all": "All", + "label.all-time": "All time", + "label.analytics": "Analytics", + "label.apply": "Apply", + "label.attribution": "Attribution", + "label.attribution-description": "See how users engage with your marketing and what drives conversions.", + "label.average": "Average", + "label.back": "Back", + "label.before": "Before", + "label.behavior": "Behavior", + "label.boards": "Boards", + "label.bounce-rate": "Bounce rate", + "label.breakdown": "Breakdown", + "label.browser": "Browser", + "label.browsers": "Browsers", + "label.campaigns": "Campaigns", + "label.cancel": "Cancel", + "label.change-password": "Change password", + "label.channels": "Channels", + "label.cities": "Cities", + "label.city": "City", + "label.clear-all": "Clear all", + "label.cohort": "Cohort", + "label.compare": "Compare", + "label.compare-dates": "Compare dates", + "label.confirm": "Confirm", + "label.confirm-password": "Confirm password", + "label.contains": "Contains", + "label.content": "Content", + "label.continue": "Continue", + "label.conversion": "Conversion", + "label.conversion-rate": "Conversion rate", + "label.conversion-step": "Conversion step", + "label.count": "Count", + "label.countries": "Countries", + "label.country": "Country", + "label.create": "Create", + "label.create-report": "Create report", + "label.create-team": "Create team", + "label.create-user": "Create user", + "label.created": "Created", + "label.created-by": "Created By", + "label.currency": "Currency", + "label.current": "Current", + "label.current-password": "Current password", + "label.custom-range": "Custom range", + "label.dashboard": "Dashboard", + "label.data": "Data", + "label.date": "Date", + "label.date-range": "Date range", + "label.day": "Day", + "label.default-date-range": "Default date range", + "label.delete": "Delete", + "label.delete-report": "Delete report", + "label.delete-team": "Delete team", + "label.delete-user": "Delete user", + "label.delete-website": "Delete website", + "label.description": "Description", + "label.desktop": "Desktop", + "label.details": "Details", + "label.device": "Device", + "label.devices": "Devices", + "label.direct": "Direct", + "label.dismiss": "Dismiss", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "Does not contain", + "label.does-not-include": "Does not include", + "label.doest-not-exist": "Does not exist", + "label.domain": "Domain", + "label.dropoff": "Dropoff", + "label.edit": "Edit", + "label.edit-dashboard": "Edit dashboard", + "label.edit-member": "Edit member", + "label.email": "Email", + "label.enable-share-url": "Enable share URL", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "Event", + "label.event-data": "Event data", + "label.event-name": "Event name", + "label.events": "Events", + "label.exists": "Exists", + "label.exit": "Exit URL", + "label.false": "False", + "label.field": "Field", + "label.fields": "Fields", + "label.filter": "Filter", + "label.filter-combined": "Combined", + "label.filter-raw": "Raw", + "label.filters": "Filters", + "label.first-click": "First click", + "label.first-seen": "First seen", + "label.funnel": "Funnel", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Funnels", + "label.goal": "Goal", + "label.goals": "Goals", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "Greater than", + "label.greater-than-equals": "Greater than or equals", + "label.grouped": "Grouped", + "label.hostname": "Hostname", + "label.includes": "Includes", + "label.insight": "Insight", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "Is", + "label.is-false": "Is false", + "label.is-not": "Is not", + "label.is-not-set": "Is not set", + "label.is-set": "Is set", + "label.is-true": "Is true", + "label.join": "Join", + "label.join-team": "Join team", + "label.journey": "Journey", + "label.journey-description": "Understand how users navigate through your website.", + "label.journeys": "Journeys", + "label.language": "Language", + "label.languages": "Languages", + "label.laptop": "Laptop", + "label.last-click": "Last click", + "label.last-days": "Last {x} days", + "label.last-hours": "Last {x} hours", + "label.last-months": "Last {x} months", + "label.last-seen": "Last seen", + "label.leave": "Leave", + "label.leave-team": "Leave team", + "label.less-than": "Less than", + "label.less-than-equals": "Less than or equals", + "label.links": "Links", + "label.login": "Login", + "label.logout": "Logout", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Expand", + "label.medium": "Medium", + "label.member": "Member", + "label.members": "Members", + "label.min": "Min", + "label.mobile": "Mobile", + "label.model": "Model", + "label.more": "More", + "label.my-account": "My account", + "label.my-websites": "My websites", + "label.name": "Name", + "label.new-password": "New password", + "label.none": "None", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organic search", + "label.organic-shopping": "Organic shopping", + "label.organic-social": "Organic social", + "label.organic-video": "Organic video", + "label.os": "OS", + "label.other": "Other", + "label.overview": "Overview", + "label.owner": "Owner", + "label.page": "Page", + "label.page-of": "Page {current} of {total}", + "label.page-views": "Page views", + "label.pageTitle": "Page title", + "label.pages": "Pages", + "label.paid-ads": "Paid ads", + "label.paid-search": "Paid search", + "label.paid-shopping": "Paid shopping", + "label.paid-social": "Paid social", + "label.paid-video": "Paid video", + "label.password": "Password", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "Pixels", + "label.powered-by": "Powered by {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Profile", + "label.properties": "Properties", + "label.property": "Property", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "Realtime", + "label.referral": "Referral", + "label.referrer": "Referrer", + "label.referrers": "Referrers", + "label.refresh": "Refresh", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Remaining", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "Required", + "label.reset": "Reset", + "label.reset-website": "Reset statistics", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Save", + "label.screens": "Screens", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Select filter", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Session", + "label.session-data": "Session data", + "label.sessions": "Sessions", + "label.settings": "Settings", + "label.share": "Share", + "label.share-url": "Share URL", + "label.single-day": "Single day", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "Tablet", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Team settings", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Terms", + "label.theme": "Theme", + "label.this-month": "This month", + "label.this-week": "This week", + "label.this-year": "This year", + "label.timezone": "Timezone", + "label.title": "Title", + "label.today": "Today", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Tracking code", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Unique visitors", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Unknown", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Username", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "View details", + "label.view-only": "View only", + "label.views": "Views", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Visit duration", + "label.visitors": "Visitors", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Websites", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Are you sure you want to delete {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are you sure you want to reset {target}'s statistics?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "All associated data will be deleted as well.", + "message.error": "Something went wrong.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Go to settings", + "message.incorrect-username-password": "Incorrect username/password.", + "message.invalid-domain": "Invalid domain", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "No data available.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Passwords don't match", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "You don't have any websites configured.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Page not found.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.", + "message.saved": "Saved successfully.", + "message.sever-error": "Server error", + "message.share-url": "This is the publicly shared URL for {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Tracking code", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Visitor from {country} using {browser} on {os} {device}" +} diff --git a/src/lang/en-US.json b/src/lang/en-US.json new file mode 100644 index 0000000..3e588f5 --- /dev/null +++ b/src/lang/en-US.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Access code", + "label.actions": "Actions", + "label.activity": "Activity", + "label.add": "Add", + "label.add-board": "Add board", + "label.add-description": "Add description", + "label.add-member": "Add member", + "label.add-step": "Add step", + "label.add-website": "Add website", + "label.admin": "Admin", + "label.affiliate": "Affiliate", + "label.after": "After", + "label.all": "All", + "label.all-time": "All time", + "label.analytics": "Analytics", + "label.apply": "Apply", + "label.attribution": "Attribution", + "label.attribution-description": "See how users engage with your marketing and what drives conversions.", + "label.average": "Average", + "label.back": "Back", + "label.before": "Before", + "label.boards": "Boards", + "label.bounce-rate": "Bounce rate", + "label.breakdown": "Breakdown", + "label.browser": "Browser", + "label.browsers": "Browsers", + "label.campaigns": "Campaigns", + "label.cancel": "Cancel", + "label.change-password": "Change password", + "label.channels": "Channels", + "label.cities": "Cities", + "label.city": "City", + "label.clear-all": "Clear all", + "label.cohort": "Cohort", + "label.compare": "Compare", + "label.compare-dates": "Compare dates", + "label.confirm": "Confirm", + "label.confirm-password": "Confirm password", + "label.contains": "Contains", + "label.content": "Content", + "label.continue": "Continue", + "label.conversion": "Conversion", + "label.conversion-rate": "Conversion rate", + "label.conversion-step": "Conversion step", + "label.count": "Count", + "label.countries": "Countries", + "label.country": "Country", + "label.create": "Create", + "label.create-report": "Create report", + "label.create-team": "Create team", + "label.create-user": "Create user", + "label.created": "Created", + "label.created-by": "Created By", + "label.currency": "Currency", + "label.current": "Current", + "label.current-password": "Current password", + "label.custom-range": "Custom range", + "label.dashboard": "Dashboard", + "label.data": "Data", + "label.date": "Date", + "label.date-range": "Date range", + "label.day": "Day", + "label.default-date-range": "Default date range", + "label.delete": "Delete", + "label.delete-report": "Delete report", + "label.delete-team": "Delete team", + "label.delete-user": "Delete user", + "label.delete-website": "Delete website", + "label.description": "Description", + "label.desktop": "Desktop", + "label.details": "Details", + "label.device": "Device", + "label.devices": "Devices", + "label.direct": "Direct", + "label.dismiss": "Dismiss", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "Does not contain", + "label.does-not-include": "Does not include", + "label.doest-not-exist": "Does not exist", + "label.domain": "Domain", + "label.dropoff": "Dropoff", + "label.edit": "Edit", + "label.edit-dashboard": "Edit dashboard", + "label.edit-member": "Edit member", + "label.email": "Email", + "label.enable-share-url": "Enable share URL", + "label.end-step": "End Step", + "label.entry": "Entry page", + "label.event": "Event", + "label.event-data": "Event data", + "label.event-name": "Event name", + "label.events": "Events", + "label.exists": "Exists", + "label.exit": "Exit page", + "label.false": "False", + "label.field": "Field", + "label.fields": "Fields", + "label.filter": "Filter", + "label.filter-combined": "Combined", + "label.filter-raw": "Raw", + "label.filters": "Filters", + "label.first-click": "First click", + "label.first-seen": "First seen", + "label.funnel": "Funnel", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Funnels", + "label.goal": "Goal", + "label.goals": "Goals", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "Greater than", + "label.greater-than-equals": "Greater than or equals", + "label.grouped": "Grouped", + "label.hostname": "Hostname", + "label.includes": "Includes", + "label.insight": "Insight", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "Is", + "label.is-false": "Is false", + "label.is-not": "Is not", + "label.is-not-set": "Is not set", + "label.is-set": "Is set", + "label.is-true": "Is true", + "label.join": "Join", + "label.join-team": "Join team", + "label.journey": "Journey", + "label.journey-description": "Understand how users navigate through your website.", + "label.journeys": "Journeys", + "label.language": "Language", + "label.languages": "Languages", + "label.laptop": "Laptop", + "label.last-click": "Last click", + "label.last-days": "Last {x} days", + "label.last-hours": "Last {x} hours", + "label.last-months": "Last {x} months", + "label.last-seen": "Last seen", + "label.leave": "Leave", + "label.leave-team": "Leave team", + "label.less-than": "Less than", + "label.less-than-equals": "Less than or equals", + "label.links": "Links", + "label.login": "Login", + "label.logout": "Logout", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Maximize", + "label.medium": "Medium", + "label.member": "Member", + "label.members": "Members", + "label.min": "Min", + "label.mobile": "Mobile", + "label.model": "Model", + "label.more": "More", + "label.my-account": "My account", + "label.my-websites": "My websites", + "label.name": "Name", + "label.new-password": "New password", + "label.none": "None", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organic search", + "label.organic-shopping": "Organic shopping", + "label.organic-social": "Organic social", + "label.organic-video": "Organic video", + "label.os": "OS", + "label.other": "Other", + "label.overview": "Overview", + "label.owner": "Owner", + "label.page": "Page", + "label.page-of": "Page {current} of {total}", + "label.page-views": "Page views", + "label.pageTitle": "Page title", + "label.pages": "Pages", + "label.paid-ads": "Paid ads", + "label.paid-search": "Paid search", + "label.paid-shopping": "Paid shopping", + "label.paid-social": "Paid social", + "label.paid-video": "Paid video", + "label.password": "Password", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "Pixels", + "label.powered-by": "Powered by {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Profile", + "label.properties": "Properties", + "label.property": "Property", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "Realtime", + "label.referral": "Referral", + "label.referrer": "Referrer", + "label.referrers": "Referrers", + "label.refresh": "Refresh", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Remaining", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "Required", + "label.reset": "Reset", + "label.reset-website": "Reset website", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue data and how users are spending.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Save", + "label.screens": "Screens", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Select filter", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Session", + "label.session-data": "Session data", + "label.sessions": "Sessions", + "label.settings": "Settings", + "label.share": "Share", + "label.share-url": "Share URL", + "label.single-day": "Single day", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "Tablet", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Team settings", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Terms", + "label.theme": "Theme", + "label.this-month": "This month", + "label.this-week": "This week", + "label.this-year": "This year", + "label.timezone": "Timezone", + "label.title": "Title", + "label.today": "Today", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Tracking code", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Unique visitors", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Unknown", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Username", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "View details", + "label.view-only": "View only", + "label.views": "Views", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Visit duration", + "label.visitors": "Visitors", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Websites", + "label.window": "Window", + "label.yesterday": "Yesterday", + "label.behavior": "Behavior", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} current {x, plural, one {visitor} other {visitors}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Are you sure you want to delete {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are you sure you want to reset {target}?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "All website data will be deleted.", + "message.error": "Something went wrong.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Go to settings", + "message.incorrect-username-password": "Incorrect username and/or password.", + "message.invalid-domain": "Invalid domain. Do not include http/https.", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "No data available.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Passwords do not match.", + "message.no-results-found": "No results found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "You do not have any websites configured.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Page not found", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your settings will remain intact.", + "message.saved": "Saved.", + "message.sever-error": "Server error", + "message.share-url": "Your website stats are publicly available at the following URL:", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "To track stats for this website, place the following code in the <head>...</head> section of your HTML.", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Visitor from {country} using {browser} on {os} {device}" +} diff --git a/src/lang/es-ES.json b/src/lang/es-ES.json new file mode 100644 index 0000000..e3a4d38 --- /dev/null +++ b/src/lang/es-ES.json @@ -0,0 +1,340 @@ +{ + "label.access-code": "Código de acceso", + "label.actions": "Acciones", + "label.activity": "Registro de actividad", + "label.add": "Añadir", + "label.add-board": "Añadir tablero", + "label.add-description": "Añadir descripción", + "label.add-member": "Añadir miembro", + "label.add-step": "Añadir paso", + "label.add-website": "Nuevo sitio web", + "label.admin": "Administrador", + "label.affiliate": "Afiliado", + "label.after": "Después", + "label.all": "Todos", + "label.all-time": "Todos los tiempos", + "label.analytics": "Analíticas", + "label.apply": "Aplicar", + "label.attribution": "Atribución", + "label.attribution-description": "Vea cómo los usuarios interactúan con su marketing y qué impulsa las conversiones.", + "label.average": "Media", + "label.back": "Atrás", + "label.before": "Antes", + "label.boards": "Tableros", + "label.bounce-rate": "Porcentaje de rebote", + "label.breakdown": "Desglose", + "label.browser": "Navegador", + "label.browsers": "Navegadores", + "label.campaigns": "Campañas", + "label.cancel": "Cancelar", + "label.change-password": "Cambiar contraseña", + "label.channels": "Canales", + "label.cities": "Ciudades", + "label.city": "Ciudad", + "label.clear-all": "Limpiar todo", + "label.cohort": "Cohorte", + "label.compare": "Comparar", + "label.compare-dates": "Comparar fechas", + "label.confirm": "Confirmar", + "label.confirm-password": "Confirmar contraseña", + "label.contains": "Contiene", + "label.content": "Contenido", + "label.continue": "Continuar", + "label.conversion": "Conversión", + "label.conversion-rate": "Tasa de conversión", + "label.conversion-step": "Paso de conversión", + "label.count": "Contar", + "label.countries": "Países", + "label.country": "País", + "label.create": "Crear", + "label.create-report": "Crear informe", + "label.create-team": "Crear equipo", + "label.create-user": "Crear usuario", + "label.created": "Creado", + "label.created-by": "Creado por", + "label.currency": "Moneda", + "label.current": "Actual", + "label.current-password": "Contraseña actual", + "label.custom-range": "Intervalo personalizado", + "label.dashboard": "Panel de control", + "label.data": "Datos", + "label.date": "Fecha", + "label.date-range": "Intervalo de fechas", + "label.day": "Día", + "label.default-date-range": "Intervalo por defecto", + "label.delete": "Eliminar", + "label.delete-report": "Eliminar reporte", + "label.delete-team": "Eliminar equipo", + "label.delete-user": "Eliminar usuario", + "label.delete-website": "Eliminar sitio", + "label.description": "Descripción", + "label.desktop": "Escritorio", + "label.details": "Detalles", + "label.device": "Dispositivo", + "label.devices": "Dispositivos", + "label.direct": "Directo", + "label.dismiss": "Cerrar", + "label.distinct-id": "ID distinto", + "label.does-not-contain": "No contiene", + "label.does-not-include": "No incluye", + "label.doest-not-exist": "No existe", + "label.domain": "Dominio", + "label.dropoff": "Abandono", + "label.edit": "Editar", + "label.edit-dashboard": "Editar panel", + "label.edit-member": "Editar miembro", + "label.email": "Email", + "label.enable-share-url": "Habilitar compartir URL", + "label.end-step": "Paso final", + "label.entry": "URL de entrada", + "label.event": "Evento", + "label.event-data": "Datos de evento", + "label.event-name": "Nombre del evento", + "label.events": "Eventos", + "label.exists": "Existe", + "label.exit": "URL de salida", + "label.false": "Falso", + "label.field": "Campo", + "label.fields": "Campos", + "label.filter": "Filtro", + "label.filter-combined": "Combinado", + "label.filter-raw": "En crudo", + "label.filters": "Filtros", + "label.first-click": "Primer clic", + "label.first-seen": "Primera vez visto", + "label.funnel": "Embudo", + "label.funnel-description": "Comprender conversión y abandono de usuarios.", + "label.funnels": "Embudos", + "label.goal": "Objetivo", + "label.goals": "Objetivos", + "label.goals-description": "Realice un seguimiento de sus objetivos de páginas vistas y eventos.", + "label.greater-than": "Mayor que", + "label.greater-than-equals": "Mayor que o igual a", + "label.grouped": "Agrupado", + "label.hostname": "Nombre de host", + "label.includes": "Incluye", + "label.insight": "Perspectiva", + "label.insights": "Perspectivas", + "label.insights-description": "Profundice en sus datos mediante el uso de segmentos y filtros.", + "label.is": "Es igual a", + "label.is-false": "Es falso", + "label.is-not": "No es igual a", + "label.is-not-set": "No está establecido", + "label.is-set": "Está establecido", + "label.is-true": "Es verdadero", + "label.join": "Unir", + "label.join-team": "Unirse al equipo", + "label.journey": "Viaje", + "label.journey-description": "Comprenda cómo los usuarios navegan por su sitio web.", + "label.journeys": "Viajes", + "label.language": "Idioma", + "label.languages": "Idiomas", + "label.laptop": "Portátil", + "label.last-click": "Último clic", + "label.last-days": "Últimos {x} días", + "label.last-hours": "Últimas {x} horas", + "label.last-months": "Últimos {x} meses", + "label.last-seen": "Visto por última vez", + "label.leave": "Abandonar", + "label.leave-team": "Abandonar equipo", + "label.less-than": "Menor que", + "label.less-than-equals": "Menor que o igual a", + "label.links": "Enlaces", + "label.login": "Iniciar sesión", + "label.logout": "Cerrar sesión", + "label.manage": "Administrar", + "label.manager": "Gerente", + "label.max": "Máximo", + "label.maximize": "Expandir", + "label.medium": "Medio", + "label.member": "Miembro", + "label.members": "Miembros", + "label.min": "Mínimo", + "label.mobile": "Móvil", + "label.model": "Modelo", + "label.more": "Más", + "label.my-account": "Mi cuenta", + "label.my-websites": "Mis sitios web", + "label.name": "Nombre", + "label.new-password": "Nueva contraseña", + "label.none": "Ninguno", + "label.number-of-records": "{x} {x, plural, one {registro} other {registros}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Búsqueda orgánica", + "label.organic-shopping": "Compras orgánicas", + "label.organic-social": "Social orgánico", + "label.organic-video": "Video orgánico", + "label.os": "Sistema", + "label.other": "Otro", + "label.overview": "Resumen", + "label.owner": "Propietario", + "label.page": "Página", + "label.page-of": "Página {current} de {total}", + "label.page-views": "Vistas", + "label.pageTitle": "Título de página", + "label.pages": "Páginas", + "label.paid-ads": "Anuncios pagados", + "label.paid-search": "Búsqueda pagada", + "label.paid-shopping": "Compras pagadas", + "label.paid-social": "Social pagado", + "label.paid-video": "Video pagado", + "label.password": "Contraseña", + "label.path": "Ruta", + "label.paths": "Rutas", + "label.pixels": "Píxeles", + "label.powered-by": "Analíticas de {name}", + "label.previous": "Anterior", + "label.previous-period": "Periodo anterior", + "label.previous-year": "Año anterior", + "label.profile": "Perfil", + "label.properties": "Propiedades", + "label.property": "Propiedad", + "label.queries": "Consultas", + "label.query": "Consulta", + "label.query-parameters": "Parámetros de consulta", + "label.realtime": "Tiempo real", + "label.referral": "Referencia", + "label.referrer": "Referido", + "label.referrers": "Referido desde", + "label.refresh": "Actualizar", + "label.regenerate": "Regenerar", + "label.region": "Región", + "label.regions": "Regiones", + "label.remaining": "Restante", + "label.remove": "Quitar", + "label.remove-member": "Eliminar miembro", + "label.reports": "Informes", + "label.required": "Obligatorio", + "label.reset": "Reiniciar", + "label.reset-website": "Reiniciar analíticas", + "label.retention": "Retención", + "label.retention-description": "Medir la frecuencia con la que los usuarios vuelven a tu sitio web.", + "label.revenue": "Ganancias", + "label.revenue-description": "Analice sus ganancias a lo largo del tiempo.", + "label.revenue-property": "Propiedad de ganancias", + "label.role": "Rol", + "label.run-query": "Ejecutar consulta", + "label.save": "Guardar", + "label.screens": "Pantallas", + "label.search": "Buscar", + "label.select": "Seleccionar", + "label.select-date": "Seleccionar fecha", + "label.select-filter": "Seleccionar filtro", + "label.select-role": "Seleccionar rol", + "label.select-website": "Seleccionar sitio web", + "label.session": "Sesión", + "label.sessions": "Sesiones", + "label.settings": "Ajustes", + "label.share": "Compartir", + "label.share-url": "Compartir URL", + "label.single-day": "Un solo día", + "label.sms": "SMS", + "label.sources": "Fuentes", + "label.start-step": "Paso inicial", + "label.steps": "Pasos", + "label.sum": "Suma", + "label.tablet": "Tableta", + "label.tag": "Etiqueta", + "label.tags": "Etiquetas", + "label.team": "Equipo", + "label.team-id": "ID del equipo", + "label.team-manager": "Jefe de equipo", + "label.team-member": "Miembro del equipo", + "label.team-name": "Nombre del equipo", + "label.team-owner": "Admin. del equipo", + "label.team-settings": "Configuración del equipo", + "label.team-view-only": "Vista solo del equipo", + "label.team-websites": "Sitios web del equipo", + "label.teams": "Equipos", + "label.terms": "Términos", + "label.theme": "Tema", + "label.this-month": "Este mes", + "label.this-week": "Esta semana", + "label.this-year": "Este año", + "label.timezone": "Zona horaria", + "label.title": "Título", + "label.today": "Hoy", + "label.toggle-charts": "Alternar gráficas", + "label.total": "Total", + "label.total-records": "Total de registros", + "label.tracking-code": "Código de rastreo", + "label.transactions": "Transacciones", + "label.transfer": "Transferir", + "label.transfer-website": "Transferir sitio web", + "label.true": "Verdadero", + "label.type": "Tipo", + "label.unique": "Único", + "label.unique-visitors": "Visitantes únicos", + "label.uniqueCustomers": "Clientes únicos", + "label.unknown": "Desconocida", + "label.untitled": "Sin título", + "label.update": "Actualizar", + "label.user": "Usuario", + "label.user-property": "Propiedad de usuario", + "label.username": "Nombre de usuario", + "label.users": "Usuarios", + "label.utm": "UTM", + "label.utm-description": "Realice un seguimiento de sus campañas a través de parámetros UTM.", + "label.value": "Valor", + "label.view": "Visualizar", + "label.view-details": "Ver detalles", + "label.view-only": "Ver sólo", + "label.views": "Vistas", + "label.views-per-visit": "Vistas por visita", + "label.visit-duration": "Tiempo promedio de visita", + "label.visitors": "Visitantes", + "label.visits": "Visitas", + "label.website": "Sitio web", + "label.website-id": "ID del sitio web", + "label.websites": "Sitios web", + "label.window": "Ventana", + "label.yesterday": "Ayer", + "label.behavior": "Comportamiento", + "message.action-confirmation": "Escriba {confirmation} en el cuadro a continuación para confirmar.", + "message.active-users": "{x} {x, plural, one {activo} other {activos}}", + "message.bad-request": "Bad request", + "message.collected-data": "Datos obtenidos", + "message.confirm-delete": "¿Seguro que quieres eliminar {target}?", + "message.confirm-leave": "¿Seguro que quieres abandonar {target}?", + "message.confirm-remove": "¿Estás seguro de que desea eliminar {target}?", + "message.confirm-reset": "¿Seguro que quieres BORRAR las analíticas de {target}?", + "message.delete-team-warning": "Al eliminar un equipo, también se eliminarán todos los sitios web del equipo.", + "message.delete-website-warning": "Toda la información relacionada será eliminada.", + "message.error": "Algo falló.", + "message.event-log": "{event} en {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Ir a la configuración", + "message.incorrect-username-password": "Nombre de usuario o contraseña incorrectos.", + "message.invalid-domain": "Dominio inválido", + "message.min-password-length": "Longitud mínima de {n} caracteres", + "message.new-version-available": "Una nueva versión de Umami {version} está disponible", + "message.no-data-available": "No hay información disponible.", + "message.no-event-data": "No hay datos de eventos disponibles.", + "message.no-match-password": "Las contraseñas no coinciden", + "message.no-results-found": "No se encontraron resultados.", + "message.no-team-websites": "Este equipo no tiene ningún sitio web configurado.", + "message.no-teams": "No has creado ningún equipo.", + "message.no-users": "No hay usuarios.", + "message.no-websites-configured": "No tienes ningún sitio web configurado.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Página no encontrada", + "message.reset-website": "Para reiniciar este sitio web, escribe {confirmation} a continuación para confirmar.", + "message.reset-website-warning": "Todas las estadísticas de esta página serán eliminadas, pero el código de rastreo permanecerá intacto.", + "message.saved": "Guardado", + "message.sever-error": "Server error", + "message.share-url": "Esta es la URL pública para {target}.", + "message.team-already-member": "Ya eres miembro de este equipo.", + "message.team-not-found": "Equipo no encontrado.", + "message.team-websites-info": "Las analíticas de tus sitios web pueden ser vistas por cualquier miembro del equipo.", + "message.tracking-code": "Código de rastreo", + "message.transfer-team-website-to-user": "¿Transferir este sitio web a su cuenta?", + "message.transfer-user-website-to-team": "Seleccione el equipo al que transferir este sitio web.", + "message.transfer-website": "Seleccione el equipo al que transferir este sitio web.", + "message.triggered-event": "Evento lanzado", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Usuario eliminado.", + "message.viewed-page": "Página vista", + "message.visitor-log": "Visitante desde {country} usando {browser} en {os} {device}" +} diff --git a/src/lang/fa-IR.json b/src/lang/fa-IR.json new file mode 100644 index 0000000..96b3da9 --- /dev/null +++ b/src/lang/fa-IR.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "کد دسترسی", + "label.actions": "اقدامات", + "label.activity": "فعالیت", + "label.add": "افزودن", + "label.add-board": "افزودن برد", + "label.add-description": "افزودن توضیحات", + "label.add-member": "افزودن عضو", + "label.add-step": "افزودن قدم", + "label.add-website": "افزودن وبسایت", + "label.admin": "مدیر", + "label.affiliate": "همکار فروش", + "label.after": "بعد", + "label.all": "همه", + "label.all-time": "تمامی زمانها", + "label.analytics": "تجزیه و تحلیل", + "label.apply": "اعمال", + "label.attribution": "انتساب", + "label.attribution-description": "ببینید کاربران چگونه با بازاریابی شما تعامل دارند و چه چیزی باعث تبدیل میشود.", + "label.average": "میانگین", + "label.back": "بازگشت", + "label.before": "قبل از", + "label.behavior": "رفتار", + "label.boards": "بردها", + "label.bounce-rate": "نرخ ریزش", + "label.breakdown": "تفکیک", + "label.browser": "مرورگر", + "label.browsers": "مرورگرها", + "label.campaigns": "کمپینها", + "label.cancel": "انصراف", + "label.change-password": "تغییر رمز", + "label.channels": "کانالها", + "label.cities": "شهرها", + "label.city": "شهر", + "label.clear-all": "پاک کردن همه", + "label.cohort": "گروه", + "label.compare": "مقایسه", + "label.compare-dates": "مقایسه تاریخها", + "label.confirm": "تأیید", + "label.confirm-password": "تأیید رمز", + "label.contains": "شامل", + "label.content": "محتوا", + "label.continue": "ادامه", + "label.conversion": "تبدیل", + "label.conversion-rate": "نرخ تبدیل", + "label.conversion-step": "گام تبدیل", + "label.count": "تعداد", + "label.countries": "کشورها", + "label.country": "کشور", + "label.create": "ایجاد", + "label.create-report": "ایجاد گزارش", + "label.create-team": "ایجاد تیم", + "label.create-user": "ایجاد کاربر", + "label.created": "ایجاد شد", + "label.created-by": "ایجاد شده توسط", + "label.currency": "واحد پول", + "label.current": "فعلی", + "label.current-password": "رمز فعلی", + "label.custom-range": "محدودهی دلخواه", + "label.dashboard": "داشبورد", + "label.data": "داده", + "label.date": "تاریخ", + "label.date-range": "محدودهی تاریخ", + "label.day": "روز", + "label.default-date-range": "محدودهی پیشفرض تاریخ", + "label.delete": "حذف", + "label.delete-report": "حذف گزارش", + "label.delete-team": "حذف تیم", + "label.delete-user": "حذف کاربر", + "label.delete-website": "حذف وبسایت", + "label.description": "توضیحات", + "label.desktop": "دسکتاپ", + "label.details": "جزئیات", + "label.device": "دستگاه", + "label.devices": "دستگاهها", + "label.direct": "مستقیم", + "label.dismiss": "رد کردن", + "label.distinct-id": "شناسه یکتا", + "label.does-not-contain": "شامل نمیشود", + "label.does-not-include": "شامل نمیشود", + "label.doest-not-exist": "وجود ندارد", + "label.domain": "دامنه", + "label.dropoff": "رها کردن", + "label.edit": "ویرایش", + "label.edit-dashboard": "ویرایش داشبورد", + "label.edit-member": "ویرایش عضو", + "label.email": "ایمیل", + "label.enable-share-url": "فعال کردن اشتراک گذاری آدرس اینترنتی", + "label.end-step": "قدم پایانی", + "label.entry": "آدرس اینترنتی ورودی", + "label.event": "رویداد", + "label.event-data": "دادههای رویداد", + "label.event-name": "نام رویداد", + "label.events": "رویدادها", + "label.exists": "وجود دارد", + "label.exit": "آدرس اینترنتی خروجی", + "label.false": "نادرست", + "label.field": "فیلد", + "label.fields": "فیلدها", + "label.filter": "فیلتر", + "label.filter-combined": "ترکیب شده", + "label.filter-raw": "خام", + "label.filters": "فیلترها", + "label.first-click": "اولین کلیک", + "label.first-seen": "اولین بار دیده شده", + "label.funnel": "فانل", + "label.funnel-description": "نرخ تبدیل و رها کردن کاربران را درک کنید.", + "label.funnels": "قیفها", + "label.goal": "هدف", + "label.goals": "اهداف", + "label.goals-description": "اهداف خود را برای بازدید از صفحه و رویدادها دنبال کنید.", + "label.greater-than": "بزرگتر از", + "label.greater-than-equals": "بزرگتر یا مساوی", + "label.grouped": "گروهبندی شده", + "label.hostname": "نام میزبان", + "label.includes": "شامل میشود", + "label.insight": "بینش", + "label.insights": "بینش", + "label.insights-description": "با استفاده از بخشها و فیلترها، در دادههای خود عمیقتر شوید.", + "label.is": "برابر است با", + "label.is-false": "نادرست است", + "label.is-not": "برابر نیست با", + "label.is-not-set": "تعیین نشده", + "label.is-set": "تعیین شده", + "label.is-true": "درست است", + "label.join": "پیوستن", + "label.join-team": "پیوستن به تیم", + "label.journey": "مسیر", + "label.journey-description": "درک کنید که کاربران چگونه در وبسایت شما حرکت می کنند.", + "label.journeys": "مسیرها", + "label.language": "زبان", + "label.languages": "زبانها", + "label.laptop": "لپتاپ", + "label.last-click": "آخرین کلیک", + "label.last-days": "{x} روز گذشته", + "label.last-hours": "{x} ساعت گذشته", + "label.last-months": "{x} ماه گذشته", + "label.last-seen": "آخرین بار دیده شده", + "label.leave": "ترک کردن", + "label.leave-team": "ترک تیم", + "label.less-than": "کمتر از", + "label.less-than-equals": "کمتر یا مساوی", + "label.links": "لینکها", + "label.login": "ورود", + "label.logout": "خروج", + "label.manage": "مدیریت", + "label.manager": "مدیر", + "label.max": "حداکثر", + "label.maximize": "گسترش", + "label.medium": "متوسط", + "label.member": "عضو", + "label.members": "اعضا", + "label.min": "حداقل", + "label.mobile": "موبایل", + "label.model": "مدل", + "label.more": "بیشتر", + "label.my-account": "حساب کاربری من", + "label.my-websites": "وبسایتهای من", + "label.name": "نام", + "label.new-password": "رمز جدید", + "label.none": "هیچ", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "تایید", + "label.online": "Online", + "label.organic-search": "جستجوی ارگانیک", + "label.organic-shopping": "خرید ارگانیک", + "label.organic-social": "شبکه اجتماعی ارگانیک", + "label.organic-video": "ویدیوی ارگانیک", + "label.os": "سیستم عامل", + "label.other": "سایر", + "label.overview": "بررسی کلی", + "label.owner": "مالک", + "label.page": "صفحه", + "label.page-of": "صفحه {current} از {total}", + "label.page-views": "بازدید صفحه", + "label.pageTitle": "عنوان صفحه", + "label.pages": "صفحهها", + "label.paid-ads": "تبلیغات پولی", + "label.paid-search": "جستجوی پولی", + "label.paid-shopping": "خرید پولی", + "label.paid-social": "شبکه اجتماعی پولی", + "label.paid-video": "ویدیوی پولی", + "label.password": "رمز", + "label.path": "مسیر", + "label.paths": "مسیرها", + "label.pixels": "پیکسلها", + "label.powered-by": "قدرت گرفته توسط {name}", + "label.previous": "قبلی", + "label.previous-period": "دورهی قبل", + "label.previous-year": "سال قبل", + "label.profile": "پروفایل", + "label.properties": "ویژگیها", + "label.property": "ویژگی", + "label.queries": "کوئریها", + "label.query": "کوئری", + "label.query-parameters": "پارامترهای کوئری", + "label.realtime": "آمار زنده", + "label.referral": "ارجاع", + "label.referrer": "ارجاع دهنده", + "label.referrers": "ارجاع دهندگان", + "label.refresh": "بهروزرسانی", + "label.regenerate": "تولید مجدد", + "label.region": "منطقه", + "label.regions": "مناطق", + "label.remaining": "باقیمانده", + "label.remove": "حذف", + "label.remove-member": "حذف عضو", + "label.reports": "گزارشها", + "label.required": "ضروری", + "label.reset": "بازنشانی", + "label.reset-website": "بازنشانی وبسایت", + "label.retention": "نرخ بازگشت", + "label.retention-description": "چسبندگی وبسایت خود را با دنبال کردن تعداد دفعات بازگشت کاربران اندازهگیری کنید.", + "label.revenue": "درآمد", + "label.revenue-description": "به درآمد خود در طول زمان نگاه کنید.", + "label.role": "نقش", + "label.run-query": "اجرای کوئری", + "label.save": "ذخیره", + "label.screens": "صفحه", + "label.search": "جستجو", + "label.select": "انتخاب", + "label.select-date": "انتخاب تاریخ", + "label.select-filter": "انتخاب فیلتر", + "label.select-role": "انتخاب نقش", + "label.select-website": "انتخاب وبسایت", + "label.session": "نشست", + "label.session-data": "دادههای نشست", + "label.sessions": "نشستها", + "label.settings": "تنظیمات", + "label.share": "اشتراکگذاری", + "label.share-url": "به اشتراک گذاری آدرس اینترنتی", + "label.single-day": "یک روز", + "label.sms": "SMS", + "label.sources": "منابع", + "label.start-step": "قدم شروع", + "label.steps": "قدمها", + "label.sum": "جمع", + "label.tablet": "تبلت", + "label.tag": "برچسب", + "label.tags": "برچسبها", + "label.team": "تیم", + "label.team-id": "شناسه تیم", + "label.team-manager": "مدیر تیم", + "label.team-member": "عضو تیم", + "label.team-name": "نام تیم", + "label.team-owner": "مالک تیم", + "label.team-settings": "تنظیمات تیم", + "label.team-view-only": "فقط مشاهدهی تیم", + "label.team-websites": "وبسایتهای تیم", + "label.teams": "تیمها", + "label.terms": "شرایط", + "label.theme": "تم", + "label.this-month": "این ماه", + "label.this-week": "این هفته", + "label.this-year": "امسال", + "label.timezone": "منطقهی زمانی", + "label.title": "عنوان", + "label.today": "امروز", + "label.toggle-charts": "نمایش / عدم نمایش نمودارها", + "label.total": "جمع", + "label.total-records": "جمع رکوردها", + "label.tracking-code": "کد رهگیری", + "label.transactions": "تراکنشها", + "label.transfer": "انتقال", + "label.transfer-website": "انتقال وبسایت", + "label.true": "درست", + "label.type": "نوع", + "label.unique": "یکتا", + "label.unique-visitors": "بازدیدکنندههای یکتا", + "label.uniqueCustomers": "مشتریان یکتا", + "label.unknown": "ناشناخته", + "label.untitled": "بدون عنوان", + "label.update": "بهروزرسانی", + "label.user": "کاربر", + "label.username": "نام کاربری", + "label.users": "کاربران", + "label.utm": "UTM", + "label.utm-description": "با استفاده از پارامترهای UTM، کمپینهای خود را بررسی کنید.", + "label.value": "مقدار", + "label.view": "مشاهده", + "label.view-details": "مشاهدهی جزئیات", + "label.view-only": "فقط مشاهده", + "label.views": "بازدید", + "label.views-per-visit": "نمایشها در هر بازدید", + "label.visit-duration": "میانگین زمان بازدید", + "label.visitors": "بازدیدکننده", + "label.visits": "بازدیدها", + "label.website": "وبسایت", + "label.website-id": "شناسه وبسایت", + "label.websites": "وبسایتها", + "label.window": "پنجره", + "label.yesterday": "دیروز", + "message.action-confirmation": "برای تأیید این عملیات، لطفاً {confirmation} را تایپ کنید.", + "message.active-users": "{x} فعلی {x, plural, one {یک} other {از میان}}", + "message.bad-request": "Bad request", + "message.collected-data": "دادههای جمعآوری شده", + "message.confirm-delete": "آیا مطمئن هستید میخواهید {target} را حذف کنید؟", + "message.confirm-leave": "آیا مطمئن هستید میخواهید از {target} خارج شوید؟", + "message.confirm-remove": "آیا مطمئن هستید میخواهید {target} را حذف کنید؟", + "message.confirm-reset": "آیا مطمئن هستید میخواهید {target} را بازنشانی کنید؟", + "message.delete-team-warning": "با حذف تیم، تمامی وبسایتهای تیم هم حذف خواهند شد.", + "message.delete-website-warning": "همهی دادههای وبسایت هم حذف خواهد شد.", + "message.error": "مشکلی پیش آمده است.", + "message.event-log": "{event} در {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "رفتن به تنظیمات", + "message.incorrect-username-password": "نام کاربری / رمز نادرست است.", + "message.invalid-domain": "دامنه نامعتبر است.", + "message.min-password-length": "حداقل طول {n} کاراکتر است.", + "message.new-version-available": "نسخهی جدیدی از Umami {version} در دسترس است.", + "message.no-data-available": "اطلاعاتی موجود نیست.", + "message.no-event-data": "هیچ دادهای برای این رویداد وجود ندارد.", + "message.no-match-password": "رمزها یکسان نیستند", + "message.no-results-found": "نتیجهای یافت نشد.", + "message.no-team-websites": "هیچ وبسایتی برای این تیم وجود ندارد.", + "message.no-teams": "شما هیچ تیمی را ایجاد نکردهاید.", + "message.no-users": "هیچ کاربری وجود ندارد.", + "message.no-websites-configured": "شما هیچ وبسایتی را پیکربندی نکردهاید.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "صفحه یافت نشد.", + "message.reset-website": "برای بازنشانی وبسایت، لطفاً {confirmation} را تایپ کنید.", + "message.reset-website-warning": "تمامی آمارهای این وبسایت حذف خواهد شد اما کدهای رهگیری بدون تغییر باقی میماند.", + "message.saved": "ذخیره شد.", + "message.sever-error": "Server error", + "message.share-url": "آمار وبسایت شما به صورت عمومی در آدرس زیر قابل مشاهده است.", + "message.team-already-member": "شما از قبل عضو این تیم هستید.", + "message.team-not-found": "تیم یافت نشد.", + "message.team-websites-info": "وبسایتها توسط تمامی اعضای تیم قابل مشاهده هستند.", + "message.tracking-code": "کد رهگیری", + "message.transfer-team-website-to-user": "آیا میخواهید این وبسایت را به حساب خود منتقل کنید؟", + "message.transfer-user-website-to-team": "تیم مورد نظر را برای انتقال وبسایت انتخاب کنید.", + "message.transfer-website": "مالکیت وبسایت را به حساب خودت یا یک تیم دیگر منتقل کنید.", + "message.triggered-event": "رویداد فعال شده", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "کاربر حذف شد.", + "message.viewed-page": "صفحه مشاهده شد", + "message.visitor-log": "بازدیدکننده از کشور {country} با مروگر {browser} در {os} {device}" +} diff --git a/src/lang/fi-FI.json b/src/lang/fi-FI.json new file mode 100644 index 0000000..daaa62f --- /dev/null +++ b/src/lang/fi-FI.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Pääsykoodi", + "label.actions": "Toiminnat", + "label.activity": "Toimintaloki", + "label.add": "Lisää", + "label.add-board": "Lisää taulu", + "label.add-description": "Lisää kuvaus", + "label.add-member": "Lisää jäsen", + "label.add-step": "Lisää vaihe", + "label.add-website": "Lisää verkkosivu", + "label.admin": "Järjestelmänvalvoja", + "label.affiliate": "Kumppani", + "label.after": "Jälkeen", + "label.all": "Kaikki", + "label.all-time": "Alusta lähtien", + "label.analytics": "Analytiikka", + "label.apply": "Käytä", + "label.attribution": "Attribuutio", + "label.attribution-description": "Katso, miten käyttäjät ovat vuorovaikutuksessa markkinointisi kanssa ja mikä johtaa konversioihin.", + "label.average": "Keskiarvo", + "label.back": "Takaisin", + "label.before": "Ennen", + "label.boards": "Taulut", + "label.bounce-rate": "Välitön poistuminen", + "label.breakdown": "Erittele", + "label.browser": "Selain", + "label.browsers": "Selaimet", + "label.campaigns": "Kampanjat", + "label.cancel": "Peruuta", + "label.change-password": "Vaihda salasana", + "label.channels": "Kanavat", + "label.cities": "Kaupungit", + "label.city": "Kaupunki", + "label.clear-all": "Tyhjennä kaikki", + "label.cohort": "Kohortti", + "label.compare": "Vertaa", + "label.compare-dates": "Vertaa päivämääriä", + "label.confirm": "Vahvista", + "label.confirm-password": "Vahvista salasana", + "label.contains": "Contains", + "label.content": "Sisältö", + "label.continue": "Jatka", + "label.conversion": "Konversio", + "label.conversion-rate": "Konversioprosentti", + "label.conversion-step": "Konversiovaihe", + "label.count": "Lukumäärä", + "label.countries": "Maat", + "label.country": "Maa", + "label.create": "Luo", + "label.create-report": "Luo raportti", + "label.create-team": "Luo tiimi", + "label.create-user": "Luo käyttäjä", + "label.created": "Luotu", + "label.created-by": "Luonut", + "label.currency": "Valuutta", + "label.current": "Nykyinen", + "label.current-password": "Nykyinen salasana", + "label.custom-range": "Mukautettu ajanjakso", + "label.dashboard": "Ohjauspaneeli", + "label.data": "Data", + "label.date": "Päivämäärä", + "label.date-range": "Ajanjakso", + "label.day": "Päivä", + "label.default-date-range": "Oletusajanjakso", + "label.delete": "Poista", + "label.delete-report": "Poista raportti", + "label.delete-team": "Poista tiimi", + "label.delete-user": "Poista käyttäjä", + "label.delete-website": "Poista verkkosivu", + "label.description": "Kuvaus", + "label.desktop": "Pöytäkone", + "label.details": "Tiedot", + "label.device": "Laite", + "label.devices": "Laitteet", + "label.direct": "Suora", + "label.dismiss": "Hylkää", + "label.distinct-id": "Yksilöllinen ID", + "label.does-not-contain": "Ei sisällä", + "label.does-not-include": "Ei sisällä", + "label.doest-not-exist": "Ei ole olemassa", + "label.domain": "Verkkotunnus", + "label.dropoff": "Poistuminen", + "label.edit": "Muokkaa", + "label.edit-dashboard": "Muokkaa ohjauspaneelia", + "label.edit-member": "Muokkaa jäsentä", + "label.email": "Sähköposti", + "label.enable-share-url": "Ota jakamisen URL-osoite käyttöön", + "label.end-step": "Loppuvaihe", + "label.entry": "Tulo-URL", + "label.event": "Tapahtuma", + "label.event-data": "Tapahtumatiedot", + "label.event-name": "Tapahtuman nimi", + "label.events": "Tapahtumat", + "label.exists": "On olemassa", + "label.exit": "Poistumis-URL", + "label.false": "Epätosi", + "label.field": "Kenttä", + "label.fields": "Kentät", + "label.filter": "Filter", + "label.filter-combined": "Yhdistetty", + "label.filter-raw": "Käsittelemätön", + "label.filters": "Suodattimet", + "label.first-click": "Ensimmäinen klikkaus", + "label.first-seen": "Ensimmäinen havainto", + "label.funnel": "Suppilo", + "label.funnel-description": "Ymmärrä käyttäjien konversio- ja poistumisprosentti.", + "label.funnels": "Suppilot", + "label.goal": "Tavoite", + "label.goals": "Tavoitteet", + "label.goals-description": "Seuraa sivun katselujen ja tapahtumien tavoitteitasi.", + "label.greater-than": "Suurempi kuin", + "label.greater-than-equals": "Suurempi tai yhtä suuri kuin", + "label.grouped": "Ryhmitelty", + "label.hostname": "Isäntänimi", + "label.includes": "Sisältää", + "label.insight": "Oivallus", + "label.insights": "Oivallukset", + "label.insights-description": "Sukella syvemmälle tietoihisi käyttämällä segmenttejä ja suodattimia.", + "label.is": "On", + "label.is-false": "On epätosi", + "label.is-not": "Ei ole", + "label.is-not-set": "Ei asetettu", + "label.is-set": "Asetettu", + "label.is-true": "On tosi", + "label.join": "Liity", + "label.join-team": "Liity tiimiin", + "label.journey": "Polku", + "label.journey-description": "Ymmärrä, miten käyttäjät navigoivat sivustollasi.", + "label.journeys": "Polut", + "label.language": "Kieli", + "label.languages": "Kielet", + "label.laptop": "Kannettava tietokone", + "label.last-click": "Viimeinen klikkaus", + "label.last-days": "Viimeisimmät {x} päivää", + "label.last-hours": "Viimeisimmät {x} tuntia", + "label.last-months": "Viimeiset {x} kuukautta", + "label.last-seen": "Viimeksi nähty", + "label.leave": "Poistu", + "label.leave-team": "Poistu tiimistä", + "label.less-than": "Vähemmän kuin", + "label.less-than-equals": "Vähemmän tai yhtä suuri kuin", + "label.links": "Linkit", + "label.login": "Kirjaudu sisään", + "label.logout": "Kirjaudu ulos", + "label.manage": "Hallinnoi", + "label.manager": "Päällikkö", + "label.max": "Maksimi", + "label.maximize": "Laajenna", + "label.medium": "Keskitaso", + "label.member": "Jäsen", + "label.members": "Jäsenet", + "label.min": "Minimi", + "label.mobile": "Puhelin", + "label.model": "Model", + "label.more": "Lisää", + "label.my-account": "Oma tili", + "label.my-websites": "Omat verkkosivut", + "label.name": "Nimi", + "label.new-password": "Uusi salasana", + "label.none": "Ei mitään", + "label.number-of-records": "{x} {x, plural, one {tietue} other {tietuetta}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Orgaaninen haku", + "label.organic-shopping": "Orgaaninen ostaminen", + "label.organic-social": "Orgaaninen sosiaalinen", + "label.organic-video": "Orgaaninen video", + "label.os": "OS", + "label.other": "Muu", + "label.overview": "Yleiskatsaus", + "label.owner": "Omistaja", + "label.page": "Sivu", + "label.page-of": "Sivu {current} / {total}", + "label.page-views": "Sivun näyttökerrat", + "label.pageTitle": "Sivun otsikko", + "label.pages": "Sivut", + "label.paid-ads": "Maksetut mainokset", + "label.paid-search": "Maksettu haku", + "label.paid-shopping": "Maksettu ostaminen", + "label.paid-social": "Maksettu sosiaalinen", + "label.paid-video": "Maksettu video", + "label.password": "Salasana", + "label.path": "Polku", + "label.paths": "Polut", + "label.pixels": "Pikselit", + "label.powered-by": "Voimanlähteenä {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Profiili", + "label.properties": "Ominaisuudet", + "label.property": "Ominaisuus", + "label.queries": "Kyselyt", + "label.query": "Kysely", + "label.query-parameters": "Kyselyn parametrit", + "label.realtime": "Juuri nyt", + "label.referral": "Viittaus", + "label.referrer": "Referrer", + "label.referrers": "Viittaajat", + "label.refresh": "Päivitä", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Jäljellä", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "Vaaditaan", + "label.reset": "Nollaa", + "label.reset-website": "Nollaa tilastot", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Tulot", + "label.revenue-description": "Katso tulosi ajan mittaan.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Tallenna", + "label.screens": "Näytöt", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Valitse suodatin", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Istunto", + "label.session-data": "Istuntotiedot", + "label.sessions": "Sessions", + "label.settings": "Asetukset", + "label.share": "Jaa", + "label.share-url": "Jaa URL", + "label.single-day": "Yksi päivä", + "label.sms": "SMS", + "label.sources": "Lähteet", + "label.start-step": "Aloitusvaihe", + "label.steps": "Vaiheet", + "label.sum": "Sum", + "label.tablet": "Tabletti", + "label.tag": "Tunniste", + "label.tags": "Tunnisteet", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Tiimin asetukset", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Ehdot", + "label.theme": "Teema", + "label.this-month": "Tämä kuukausi", + "label.this-week": "Tämä viikko", + "label.this-year": "Tämä vuosi", + "label.timezone": "Aikavyöhyke", + "label.title": "Title", + "label.today": "Tänään", + "label.toggle-charts": "Kytke kaaviot päälle/pois", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Seurantakoodi", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Yksittäiset kävijät", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Tuntematon", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Käyttäjänimi", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Katso tiedot", + "label.view-only": "View only", + "label.views": "Näyttökerrat", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Keskimääräinen vierailuaika", + "label.visitors": "Vierailijat", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Verkkosivut", + "label.window": "Window", + "label.yesterday": "Yesterday", + "label.behavior": "Behavior", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} {x, plural, one {vierailija} other {vierailijaa}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Haluatko varmasti poistaa sivuston {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Haluatko varmasti poistaa sivuston {target} tilastot?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "Kaikki siihen liittyvät tiedot poistetaan.", + "message.error": "Jotain meni pieleen.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Mene asetuksiin", + "message.incorrect-username-password": "Väärä käyttäjänimi/salasana.", + "message.invalid-domain": "Virheellinen verkkotunnus", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Tietoja ei ole käytettävissä.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Salasanat eivät täsmää", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "Sinulla ei ole määritettyjä verkkosivustoja.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Sivua ei löydetty.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "Kaikki sivuston tilastot poistetaan, mutta seurantakoodi pysyy muuttumattomana.", + "message.saved": "Tallennettu onnistuneesti.", + "message.sever-error": "Server error", + "message.share-url": "Tämä on julkisesti jaettu URL sivustolle {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Seurantakoodi", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Vierailija maasta {country} selaimella {browser} laitteella {os} {device}" +} diff --git a/src/lang/fo-FO.json b/src/lang/fo-FO.json new file mode 100644 index 0000000..6fca425 --- /dev/null +++ b/src/lang/fo-FO.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Aðgangskoda", + "label.actions": "Gerðir", + "label.activity": "Activity log", + "label.add": "Legg afturat", + "label.add-board": "Legg borð afturat", + "label.add-description": "Legg lýsing afturat", + "label.add-member": "Legg lim afturat", + "label.add-step": "Legg stig afturat", + "label.add-website": "Legg heimasíðu afturat", + "label.admin": "Fyrisitari", + "label.affiliate": "Samband", + "label.after": "Eftir", + "label.all": "Alt", + "label.all-time": "Allur tíðin", + "label.analytics": "Greining", + "label.apply": "Nýt", + "label.attribution": "Áseting", + "label.attribution-description": "Síggj hvussu brúkarar samskifta við marknaðarføringina og hvat førir til umvendingar.", + "label.average": "Miðal", + "label.back": "Aftur", + "label.before": "Áðrenn", + "label.behavior": "Atferð", + "label.boards": "Borð", + "label.bounce-rate": "Bounce prosenttal", + "label.breakdown": "Sundurgreining", + "label.browser": "Kagi", + "label.browsers": "Kagar", + "label.campaigns": "Herferðir", + "label.cancel": "Strika", + "label.change-password": "Skift loyniorð", + "label.channels": "Rásir", + "label.cities": "Býir", + "label.city": "Býur", + "label.clear-all": "Tøm alt", + "label.cohort": "Bólkur", + "label.compare": "Samanber", + "label.compare-dates": "Samanber dato", + "label.confirm": "Staðfest", + "label.confirm-password": "Vátta loyniorð", + "label.contains": "Inniheldur", + "label.content": "Innihald", + "label.continue": "Halt fram", + "label.conversion": "Umvending", + "label.conversion-rate": "Umvendingarprosent", + "label.conversion-step": "Umvendingarstigur", + "label.count": "Tal", + "label.countries": "Lond", + "label.country": "Land", + "label.create": "Stovna", + "label.create-report": "Stovna frágreiðing", + "label.create-team": "Stovna lið", + "label.create-user": "Stovna brúkara", + "label.created": "Stovnaður", + "label.created-by": "Stovnaður av", + "label.currency": "Gjaldoyra", + "label.current": "Núverandi", + "label.current-password": "Núverandi loyniorð", + "label.custom-range": "Tillaga spenni", + "label.dashboard": "Yvirlitsskíggi", + "label.data": "Dáta", + "label.date": "Dato", + "label.date-range": "Vel dato", + "label.day": "Dagur", + "label.default-date-range": "Forsett dato", + "label.delete": "Sletta", + "label.delete-report": "Strika frágreiðing", + "label.delete-team": "Strika lið", + "label.delete-user": "Strika brúkara", + "label.delete-website": "Sletta heimasíðu", + "label.description": "Lýsing", + "label.desktop": "Borðtelda", + "label.details": "Nærri upplýsingar", + "label.device": "Tól", + "label.devices": "Tóleindir", + "label.direct": "Beinleiðis", + "label.dismiss": "Lat fara", + "label.distinct-id": "Sermerkt ID", + "label.does-not-contain": "Inniheldur ikki", + "label.does-not-include": "Er ikki við", + "label.doest-not-exist": "Er ikki til", + "label.domain": "Økisnavn", + "label.dropoff": "Dropoff", + "label.edit": "Ger broyting", + "label.edit-dashboard": "Ritstjórna yvirlitsskíggja", + "label.edit-member": "Ritstjórna lim", + "label.email": "Teldupostur", + "label.enable-share-url": "Virkja deili leinki", + "label.end-step": "Endastigur", + "label.entry": "Inngangs URL", + "label.event": "Tiltak", + "label.event-data": "Tiltaksdata", + "label.event-name": "Tiltaksnavn", + "label.events": "Hendingar/tiltøk", + "label.exists": "Er til", + "label.exit": "Útgangs URL", + "label.false": "Falskt", + "label.field": "Øki", + "label.fields": "Øki", + "label.filter": "Sía", + "label.filter-combined": "Samansett", + "label.filter-raw": "Óviðgjørt", + "label.filters": "Síur", + "label.first-click": "Fyrsta trýst", + "label.first-seen": "Fyrst sæddur", + "label.funnel": "Traktari", + "label.funnel-description": "Fá yvirlit yvir umvendingar og fráfall hjá brúkarum.", + "label.funnels": "Traktarar", + "label.goal": "Mál", + "label.goals": "Mál", + "label.goals-description": "Fylg við málum fyri síðuvísingar og tiltøk.", + "label.greater-than": "Størri enn", + "label.greater-than-equals": "Størri ella javnt", + "label.grouped": "Bólkað", + "label.hostname": "Vertnavn", + "label.includes": "Inniheldur", + "label.insight": "Innlit", + "label.insights": "Innlit", + "label.insights-description": "Fá meira innlit í tínar dátur við at brúka bólkar og síur.", + "label.is": "Er", + "label.is-false": "Er falskt", + "label.is-not": "Er ikki", + "label.is-not-set": "Er ikki sett", + "label.is-set": "Er sett", + "label.is-true": "Er satt", + "label.join": "Luttak", + "label.join-team": "Luttak í liði", + "label.journey": "Ferð", + "label.journey-description": "Fá yvirlit yvir hvussu brúkarar ferðast á heimasíðuni.", + "label.journeys": "Ferðir", + "label.language": "Mál", + "label.languages": "Mál", + "label.laptop": "Fartelda", + "label.last-click": "Seinasta trýst", + "label.last-days": "Seinastu {x} dagarnar", + "label.last-hours": "Seinastu {x} tímarnar", + "label.last-months": "Seinastu {x} mánaðirnar", + "label.last-seen": "Síðst sæddur", + "label.leave": "Far burtur", + "label.leave-team": "Far úr liði", + "label.less-than": "Minni enn", + "label.less-than-equals": "Minni ella javnt", + "label.links": "Leinkjur", + "label.login": "Rita inn", + "label.logout": "Rita út", + "label.manage": "Stýra", + "label.manager": "Stjóri", + "label.max": "Mest", + "label.maximize": "Víðka", + "label.medium": "Miðal", + "label.member": "Limur", + "label.members": "Limir", + "label.min": "Minst", + "label.mobile": "Telefon", + "label.model": "Model", + "label.more": "Meira", + "label.my-account": "Mín konto", + "label.my-websites": "Mínar heimasíður", + "label.name": "Navn", + "label.new-password": "Nýtt loyniorð", + "label.none": "Eingin", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organisk leiting", + "label.organic-shopping": "Organisk keyp", + "label.organic-social": "Organisk sosial miðla", + "label.organic-video": "Organisk video", + "label.os": "OS", + "label.other": "Annað", + "label.overview": "Yvirlit", + "label.owner": "Eigari", + "label.page": "Síða", + "label.page-of": "Síða {current} av {total}", + "label.page-views": "Opnaðar síðir", + "label.pageTitle": "Síðuheiti", + "label.pages": "Síðir", + "label.paid-ads": "Goldnar lýsingar", + "label.paid-search": "Goldin leiting", + "label.paid-shopping": "Goldið keyp", + "label.paid-social": "Goldin sosial miðla", + "label.paid-video": "Goldið video", + "label.password": "Loyniorð", + "label.path": "Leið", + "label.paths": "Leiðir", + "label.pixels": "Pikslur", + "label.powered-by": "Rikið av {name}", + "label.previous": "Fyrra", + "label.previous-period": "Fyrra tíðarskeið", + "label.previous-year": "Fyrra ár", + "label.profile": "Vangi", + "label.properties": "Eginleikar", + "label.property": "Eginleiki", + "label.queries": "Fyrispurningar", + "label.query": "Fyrispurningur", + "label.query-parameters": "Fyrispurningsparametrar", + "label.realtime": "Beinleiðis", + "label.referral": "Ávísing", + "label.referrer": "Ávísari", + "label.referrers": "Framsendingar", + "label.refresh": "Dagfør", + "label.regenerate": "Endurskapa", + "label.region": "Øki", + "label.regions": "Øki", + "label.remaining": "Eftir", + "label.remove": "Fjern", + "label.remove-member": "Fjern lim", + "label.reports": "Frágreiðingar", + "label.required": "Kravið", + "label.reset": "Nulstilla", + "label.reset-website": "Nulstilla heimasíðu", + "label.retention": "Hald", + "label.retention-description": "Mát hvussu ofta brúkarar koma aftur á tína síðu.", + "label.revenue": "Inntøka", + "label.revenue-description": "Fá yvirlit yvir inntøku yvir tíð.", + "label.role": "Leiklutur", + "label.run-query": "Koyr fyrispurning", + "label.save": "Goym", + "label.screens": "Skíggjar", + "label.search": "Leita", + "label.select": "Vel", + "label.select-date": "Vel dato", + "label.select-filter": "Vel síu", + "label.select-role": "Vel leiklut", + "label.select-website": "Vel heimasíðu", + "label.session": "Seta", + "label.session-data": "Setudáta", + "label.sessions": "Setur", + "label.settings": "Stillingar", + "label.share": "Deil", + "label.share-url": "Deil leinku", + "label.single-day": "Einkultur dagur", + "label.sms": "SMS", + "label.sources": "Keldur", + "label.start-step": "Byrjanarstigur", + "label.steps": "Stig", + "label.sum": "Samanlagt", + "label.tablet": "Teldil", + "label.tag": "Merki", + "label.tags": "Merki", + "label.team": "Lið", + "label.team-id": "Lið ID", + "label.team-manager": "Liðleiðari", + "label.team-member": "Liðlimur", + "label.team-name": "Liðnavn", + "label.team-owner": "Liðeigari", + "label.team-settings": "Liðstillingar", + "label.team-view-only": "Bert til at síggja lið", + "label.team-websites": "Lið heimasíður", + "label.teams": "Lið", + "label.terms": "Treytir", + "label.theme": "Evni", + "label.this-month": "Hendan mánan", + "label.this-week": "Hesa vikuna", + "label.this-year": "Hetta árið", + "label.timezone": "Tíðarsona", + "label.title": "Title", + "label.today": "Í dag", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Spori kota", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Einsýna vitjanir", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Ókent", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Brúkaranavn", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Vís frágreiðing", + "label.view-only": "View only", + "label.views": "Sýningar", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Miðal vitjurnartíð ", + "label.visitors": "Vitjandi", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Heimasíður", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} í løtuni {x, plural, one {vitjandi} other { vitjandi }}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Ert tú sikkur at tú ynskir at strika {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "Øll data ið er knýtt at verður eisini strika.", + "message.error": "Okkurt bleiv gali.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Far til stillingar", + "message.incorrect-username-password": "Skeivt brúkaranavn/loyniorð.", + "message.invalid-domain": "Ógilt økisnavn", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Einki data tøk.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Loyniorðini eru ikki eins", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "Tú hevur ongar heimasíður stillaða til.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Síðan bleiv ikki funnin.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.", + "message.saved": "Goymt.", + "message.sever-error": "Server error", + "message.share-url": "Hettar er tann almenna leinkan av {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Spori kota", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Vitjandi frá {country} brúkar {browser} á {os} {device}" +} diff --git a/src/lang/fr-FR.json b/src/lang/fr-FR.json new file mode 100644 index 0000000..cd6a96b --- /dev/null +++ b/src/lang/fr-FR.json @@ -0,0 +1,341 @@ +{ + "label.access-code": "Code d'accès", + "label.actions": "Actions", + "label.activity": "Journal d'activité", + "label.add": "Ajouter", + "label.add-board": "Ajouter un tableau", + "label.add-description": "Ajouter une description", + "label.add-member": "Ajouter un membre", + "label.add-step": "Ajouter une étape", + "label.add-website": "Ajouter un site", + "label.admin": "Administrateur", + "label.affiliate": "Affiliation", + "label.after": "Après", + "label.all": "Tout", + "label.all-time": "Toutes les données", + "label.analytics": "Analytique", + "label.apply": "Appliquer", + "label.attribution": "Attribution", + "label.attribution-description": "Découvrez comment les utilisateurs s'engagent avec votre marketing et ce qui génère des conversions.", + "label.average": "Moyenne", + "label.back": "Retour", + "label.before": "Avant", + "label.boards": "Tableaux", + "label.bounce-rate": "Taux de rebond", + "label.breakdown": "Répartition", + "label.browser": "Navigateur", + "label.browsers": "Navigateurs", + "label.campaigns": "Campagnes", + "label.cancel": "Annuler", + "label.change-password": "Changer le mot de passe", + "label.channels": "Canaux", + "label.cities": "Villes", + "label.city": "Ville", + "label.clear-all": "Réinitialiser", + "label.cohort": "Cohorte", + "label.compare": "Comparer", + "label.compare-dates": "Comparer les dates", + "label.confirm": "Confirmer", + "label.confirm-password": "Confirmation du mot de passe", + "label.contains": "Contient", + "label.content": "Contenu", + "label.continue": "Continuer", + "label.conversion": "Conversion", + "label.conversion-rate": "Taux de conversion", + "label.conversion-step": "Étape de conversion", + "label.count": "Compte", + "label.countries": "Pays", + "label.country": "Pays", + "label.create": "Créer", + "label.create-report": "Créer un rapport", + "label.create-team": "Créer une équipe", + "label.create-user": "Créer un utilisateur", + "label.created": "Créé", + "label.created-by": "Créé par", + "label.currency": "Devise", + "label.current": "Actuel", + "label.current-password": "Mot de passe actuel", + "label.custom-range": "Période personnalisée", + "label.dashboard": "Tableau de bord", + "label.data": "Données", + "label.date": "Date", + "label.date-range": "Période", + "label.day": "Jour", + "label.default-date-range": "Période par défaut", + "label.delete": "Supprimer", + "label.delete-report": "Supprimer le rapport", + "label.delete-team": "Supprimer l'équipe", + "label.delete-user": "Supprimer l'utilisateur", + "label.delete-website": "Supprimer le site", + "label.description": "Description", + "label.desktop": "Ordinateur", + "label.details": "Détails", + "label.device": "Appareil", + "label.devices": "Appareils", + "label.direct": "Direct", + "label.dismiss": "Ignorer", + "label.distinct-id": "ID distinct", + "label.does-not-contain": "Ne contient pas", + "label.does-not-include": "N'inclut pas", + "label.doest-not-exist": "N'existe pas", + "label.domain": "Domaine", + "label.dropoff": "Abandons", + "label.edit": "Modifier", + "label.edit-dashboard": "Modifier le tableau de bord", + "label.edit-member": "Modifier le membre", + "label.email": "E-mail", + "label.enable-share-url": "Activer l'URL de partage", + "label.end-step": "Étape de fin", + "label.entry": "Chemin d'entrée", + "label.event": "Évènement", + "label.event-data": "Données d'évènements", + "label.event-name": "Nom de l'évènement", + "label.events": "Évènements", + "label.exists": "Existe", + "label.exit": "Chemin de sortie", + "label.false": "Faux", + "label.field": "Champ", + "label.fields": "Champs", + "label.filter": "Filtrer", + "label.filter-combined": "Combiné", + "label.filter-raw": "Brut", + "label.filters": "Filtres", + "label.first-click": "Premier clic", + "label.first-seen": "Vu pour la première fois", + "label.funnel": "Entonnoir", + "label.funnel-description": "Comprenez les taux de conversions et d'abandons des utilisateurs.", + "label.funnels": "Entonnoirs", + "label.goal": "Objectif", + "label.goals": "Objectifs", + "label.goals-description": "Suivez vos objectifs en matière de pages vues et d'événements.", + "label.greater-than": "Supérieur à", + "label.greater-than-equals": "Supérieur ou égal à", + "label.grouped": "Groupé", + "label.hostname": "Nom d'hôte", + "label.includes": "Inclut", + "label.insight": "Aperçu", + "label.insights": "Aperçus", + "label.insights-description": "Analysez précisément vos données en utilisant des segments et des filtres.", + "label.is": "Est", + "label.is-false": "Est faux", + "label.is-not": "N'est pas", + "label.is-not-set": "N'est pas défini", + "label.is-set": "Est défini", + "label.is-true": "Est vrai", + "label.join": "Rejoindre", + "label.join-team": "Rejoindre une équipe", + "label.journey": "Parcours", + "label.journey-description": "Comprennez comment les utilisateurs naviguent sur votre site.", + "label.journeys": "Parcours", + "label.language": "Langue", + "label.languages": "Langues", + "label.laptop": "Portable", + "label.last-click": "Dernier clic", + "label.last-days": "{x} derniers jours", + "label.last-hours": "{x} dernières heures", + "label.last-months": "{x} derniers mois", + "label.last-seen": "Vu pour la dernière fois", + "label.leave": "Quitter", + "label.leave-team": "Quitter l'équipe", + "label.less-than": "Inférieur à", + "label.less-than-equals": "Inférieur ou égal à", + "label.links": "Liens", + "label.login": "Connexion", + "label.logout": "Déconnexion", + "label.manage": "Gérer", + "label.manager": "Gestionnaire", + "label.max": "Max", + "label.maximize": "Développer", + "label.medium": "Moyen", + "label.member": "Membre", + "label.members": "Membres", + "label.min": "Min", + "label.mobile": "Téléphone", + "label.model": "Modèle", + "label.more": "Plus", + "label.my-account": "Mon compte", + "label.my-websites": "Mes sites", + "label.name": "Nom", + "label.new-password": "Nouveau mot de passe", + "label.none": "Aucun", + "label.number-of-records": "{x} {x, plural, one {enregistrement} other {enregistrements}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Recherche organique", + "label.organic-shopping": "Achat organique", + "label.organic-social": "Réseau social organique", + "label.organic-video": "Vidéo organique", + "label.os": "OS", + "label.other": "Autre", + "label.overview": "Vue d'ensemble", + "label.owner": "Propriétaire", + "label.page": "Page", + "label.page-of": "Page {current} sur {total}", + "label.page-views": "Pages vues", + "label.pageTitle": "Titre de page", + "label.pages": "Pages", + "label.paid-ads": "Publicités payantes", + "label.paid-search": "Recherche payante", + "label.paid-shopping": "Achat payant", + "label.paid-social": "Réseau social payant", + "label.paid-video": "Vidéo payante", + "label.password": "Mot de passe", + "label.path": "Chemin", + "label.paths": "Chemins", + "label.pixels": "Pixels", + "label.powered-by": "Propulsé par {name}", + "label.previous": "Précédent", + "label.previous-period": "Période précédente", + "label.previous-year": "Année précédente", + "label.profile": "Profil", + "label.properties": "Propriétés", + "label.property": "Propriété", + "label.queries": "Requêtes", + "label.query": "Requête", + "label.query-parameters": "Paramètres de requête", + "label.realtime": "Temps réel", + "label.referral": "Référent", + "label.referrer": "Site référent", + "label.referrers": "Sites référents", + "label.refresh": "Rafraîchir", + "label.regenerate": "Régénérer", + "label.region": "Région", + "label.regions": "Régions", + "label.remaining": "Restant", + "label.remove": "Retirer", + "label.remove-member": "Retirer le membre", + "label.reports": "Rapports", + "label.required": "Requis", + "label.reset": "Réinitialiser", + "label.reset-website": "Réinitialiser les statistiques", + "label.retention": "Rétention", + "label.retention-description": "Mesurez l'attractivité de votre site en suivant la fréquence de retour des utilisateurs.", + "label.revenue": "Revenus", + "label.revenue-description": "Consultez vos revenus au fil du temps.", + "label.role": "Rôle", + "label.run-query": "Exécuter la requête", + "label.save": "Enregistrer", + "label.screens": "Écrans", + "label.search": "Rechercher", + "label.select": "Sélectionner", + "label.select-date": "Choisir une période", + "label.select-filter": "Sélectionner un filtre", + "label.select-role": "Choisir un rôle", + "label.select-website": "Choisir un site", + "label.session": "Session", + "label.session-data": "Données de session", + "label.sessions": "Sessions", + "label.settings": "Paramètres", + "label.share": "Partager", + "label.share-url": "URL de partage", + "label.single-day": "Journée", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "Étape de départ", + "label.steps": "Étapes", + "label.sum": "Somme", + "label.tablet": "Tablette", + "label.tag": "Étiquette", + "label.tags": "Étiquettes", + "label.team": "Équipe", + "label.team-id": "ID d'équipe", + "label.team-manager": "Manager de l'équipe", + "label.team-member": "Membre de l'équipe", + "label.team-name": "Nom de l'équipe", + "label.team-owner": "Propriétaire de l'équipe", + "label.team-settings": "Team settings", + "label.team-view-only": "Vue d'équipe uniquement", + "label.team-websites": "Sites d'équipes", + "label.teams": "Équipes", + "label.terms": "Mots clés", + "label.theme": "Thème", + "label.this-month": "Ce mois", + "label.this-week": "Cette semaine", + "label.this-year": "Cette année", + "label.timezone": "Fuseau horaire", + "label.title": "Titre", + "label.today": "Aujourd'hui", + "label.toggle-charts": "Afficher/Masquer les graphiques", + "label.total": "Total", + "label.total-records": "Nombre d'enregistrements", + "label.tracking-code": "Code de suivi", + "label.transactions": "Transactions", + "label.transfer": "Transférer", + "label.transfer-website": "Transférer le site", + "label.true": "Vrai", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Visiteurs uniques", + "label.uniqueCustomers": "Clients uniques", + "label.unknown": "Inconnu", + "label.untitled": "Sans titre", + "label.update": "Modifier", + "label.user": "Utilisateur", + "label.username": "Nom d'utilisateur", + "label.users": "Utilisateurs", + "label.utm": "UTM", + "label.utm-description": "Suivez vos campagnes via les paramètres UTM.", + "label.value": "Valeur", + "label.view": "Voir", + "label.view-details": "Voir les détails", + "label.view-only": "Consultation", + "label.views": "Vues", + "label.views-per-visit": "Vues par visite", + "label.visit-duration": "Temps de visite", + "label.visitors": "Visiteurs", + "label.visits": "Visites", + "label.website": "Site", + "label.website-id": "ID de site", + "label.websites": "Sites", + "label.window": "Fenêtre", + "label.yesterday": "Hier", + "label.behavior": "Comportement", + "label.traffic": "Trafic", + "label.segments": "Segments", + "message.action-confirmation": "Taper {confirmation} ci-dessous pour confirmer.", + "message.active-users": "{x} {x, plural, one {visiteur} other {visiteurs}} actuellement", + "message.bad-request": "Bad request", + "message.collected-data": "Donnée collectée", + "message.confirm-delete": "Êtes-vous sûr de vouloir supprimer {target} ?", + "message.confirm-leave": "Êtes-vous sûr de vouloir quitter {target} ?", + "message.confirm-remove": "Êtes-vous sûr de vouloir retirer {target} ?", + "message.confirm-reset": "Êtes-vous sûr de vouloir réinitialiser les statistiques de {target} ?", + "message.delete-team-warning": "Supprimer une équipe supprimera aussi tous les sites de cette équipe.", + "message.delete-website-warning": "Toutes les données associées seront supprimées.", + "message.error": "Un problème est survenu.", + "message.event-log": "{event} sur {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Aller aux paramètres", + "message.incorrect-username-password": "Nom d'utilisateur/Mot de passe incorrect.", + "message.invalid-domain": "Domaine invalide", + "message.min-password-length": "Taille minimale de {n} caractères", + "message.new-version-available": "Une nouvelle version d'Umami {version} est disponible !", + "message.no-data-available": "Aucune donnée disponible.", + "message.no-event-data": "Aucune donnée d'événement disponible.", + "message.no-match-password": "Les mots de passe ne correspondent pas", + "message.no-results-found": "Aucun résultat n'a été trouvé.", + "message.no-team-websites": "Cette équipe n'a aucun site.", + "message.no-teams": "Vous n'avez pas créé d'équipe.", + "message.no-users": "Aucun utilisateur.", + "message.no-websites-configured": "Vous n'avez pas configuré de site.", + "message.not-found": "Non trouvé!", + "message.nothing-selected": "Rien n'est sélectionné.", + "message.page-not-found": "Page non trouvée.", + "message.reset-website": "Pour réinitialiser ce site, taper {confirmation} ci-dessous pour confirmer.", + "message.reset-website-warning": "Toutes les statistiques pour ce site seront supprimées, mais votre code de suivi restera intact.", + "message.saved": "Enregistré.", + "message.sever-error": "Erreur serveur", + "message.share-url": "Les statistiques de votre site sont accessibles publiquement sur cette URL :", + "message.team-already-member": "Vous êtes déjà membre de cette équipe.", + "message.team-not-found": "Équipe non trouvée.", + "message.team-websites-info": "Les sites peuvent être vus par tout utilisateur dans l'équipe.", + "message.tracking-code": "Code de suivi", + "message.transfer-team-website-to-user": "Transférer ce site sur votre compte ?", + "message.transfer-user-website-to-team": "Choisir l'équipe à laquelle transférer ce site.", + "message.transfer-website": "Transférer la propriété du site sur votre compte ou à une autre équipe.", + "message.triggered-event": "Évènement déclenché", + "message.unauthorized": "Non authorisé!", + "message.user-deleted": "Utilisateur supprimé.", + "message.viewed-page": "Page vue", + "message.visitor-log": "Visiteur de {country} utilisant {browser} sur {os} {device}" +} diff --git a/src/lang/ga-ES.json b/src/lang/ga-ES.json new file mode 100644 index 0000000..2082600 --- /dev/null +++ b/src/lang/ga-ES.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Código de acceso", + "label.actions": "Accións", + "label.activity": "Rexistro de actividade", + "label.add": "Engadir", + "label.add-board": "Engadir taboleiro", + "label.add-description": "Engadir descrición", + "label.add-member": "Engadir membro", + "label.add-step": "Engadir paso", + "label.add-website": "Engadir sitio web", + "label.admin": "Administrador/a", + "label.affiliate": "Afiliado", + "label.after": "Despois", + "label.all": "Todo", + "label.all-time": "Sempre", + "label.analytics": "Analíticas", + "label.apply": "Aplicar", + "label.attribution": "Atribución", + "label.attribution-description": "Vexa como os usuarios interactúan co seu márketing e que impulsa as conversións.", + "label.average": "Media", + "label.back": "Atrás", + "label.before": "Antes", + "label.boards": "Taboleiros", + "label.bounce-rate": "Proporción de rebote", + "label.breakdown": "Desglose", + "label.browser": "Navegador", + "label.browsers": "Navegadores", + "label.campaigns": "Campañas", + "label.cancel": "Cancelar", + "label.change-password": "Mudar contrasinal", + "label.channels": "Canles", + "label.cities": "Cidades", + "label.city": "Cidade", + "label.clear-all": "Limpar todo", + "label.cohort": "Cohorte", + "label.compare": "Comparar", + "label.compare-dates": "Comparar datas", + "label.confirm": "Confirmar", + "label.confirm-password": "Confirmar contrasinal", + "label.contains": "Contén", + "label.content": "Contido", + "label.continue": "Continuar", + "label.conversion": "Conversión", + "label.conversion-rate": "Taxa de conversión", + "label.conversion-step": "Paso de conversión", + "label.count": "Reconto", + "label.countries": "Países", + "label.country": "País", + "label.create": "Crear", + "label.create-report": "Crear informe", + "label.create-team": "Crear equipo", + "label.create-user": "Crear usuario", + "label.created": "Creado", + "label.created-by": "Creado por", + "label.currency": "Moeda", + "label.current": "Actual", + "label.current-password": "Contrasinal actual", + "label.custom-range": "Rango personalizado", + "label.dashboard": "Taboleiro", + "label.data": "Datos", + "label.date": "Data", + "label.date-range": "Rango temporal", + "label.day": "Día", + "label.default-date-range": "Rango temporal por defecto", + "label.delete": "Eliminar", + "label.delete-report": "Eliminar reporte", + "label.delete-team": "Eliminar equipo", + "label.delete-user": "Eliminar usuario", + "label.delete-website": "Eliminar sitio web", + "label.description": "Descripción", + "label.desktop": "Escritorio", + "label.details": "Detalles", + "label.device": "Dispositivo", + "label.devices": "Dispositivos", + "label.direct": "Directo", + "label.dismiss": "Desbotar", + "label.distinct-id": "ID distinto", + "label.does-not-contain": "Non contén", + "label.does-not-include": "Non inclúe", + "label.doest-not-exist": "Non existe", + "label.domain": "Dominio", + "label.dropoff": "Disminución", + "label.edit": "Editar", + "label.edit-dashboard": "Editar taboleiro", + "label.edit-member": "Editar membro", + "label.email": "Correo electrónico", + "label.enable-share-url": "Activar URL de compartición", + "label.end-step": "Paso final", + "label.entry": "URL de entrada", + "label.event": "Evento", + "label.event-data": "Datos do evento", + "label.event-name": "Nome do evento", + "label.events": "Eventos", + "label.exists": "Existe", + "label.exit": "URL de saída", + "label.false": "Falso", + "label.field": "Campo", + "label.fields": "Campos", + "label.filter": "Filtro", + "label.filter-combined": "Combinado", + "label.filter-raw": "Crú", + "label.filters": "Filtros", + "label.first-click": "Primeiro clic", + "label.first-seen": "Primeira visita", + "label.funnel": "Funil", + "label.funnel-description": "Entende a taxa de conversión e de abandono dos usuarios.", + "label.funnels": "Funís", + "label.goal": "Obxectivo", + "label.goals": "Obxectivos", + "label.goals-description": "Segue os teus obxectivos de visualizacións de páxinas e eventos.", + "label.greater-than": "Maior que", + "label.greater-than-equals": "Maior ou igual que", + "label.grouped": "Agrupado", + "label.hostname": "Nome do host", + "label.includes": "Inclúe", + "label.insight": "Información", + "label.insights": "Informacións", + "label.insights-description": "Afonda nos teus datos usando segmentos e filtros.", + "label.is": "É", + "label.is-false": "É falso", + "label.is-not": "Non é", + "label.is-not-set": "Non está establecido", + "label.is-set": "Está establecido", + "label.is-true": "É verdadeiro", + "label.join": "Unirse", + "label.join-team": "Unirse ao equipo", + "label.journey": "Traxectoria", + "label.journey-description": "Entende como os usuarios navegan polo teu sitio web.", + "label.journeys": "Traxectorias", + "label.language": "Idioma", + "label.languages": "Idiomas", + "label.laptop": "Portátil", + "label.last-click": "Último clic", + "label.last-days": "Últimos {x} días", + "label.last-hours": "Últimas {x} horas", + "label.last-months": "Últimos {x} meses", + "label.last-seen": "Última visita", + "label.leave": "Deixar", + "label.leave-team": "Deixar o equipo", + "label.less-than": "Menor que", + "label.less-than-equals": "Menor ou igual que", + "label.links": "Ligazóns", + "label.login": "Acceder", + "label.logout": "Pechar sesión", + "label.manage": "Xestionar", + "label.manager": "Xestor", + "label.max": "Max", + "label.maximize": "Expandir", + "label.medium": "Medio", + "label.member": "Membro", + "label.members": "Membros", + "label.min": "Min", + "label.mobile": "Móbil", + "label.model": "Modelo", + "label.more": "Máis", + "label.my-account": "A miña conta", + "label.my-websites": "Os meus sitios web", + "label.name": "Nome", + "label.new-password": "Novo contrasinal", + "label.none": "Ningún", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Busca orgánica", + "label.organic-shopping": "Compra orgánica", + "label.organic-social": "Social orgánico", + "label.organic-video": "Vídeo orgánico", + "label.os": "Sistema operativo", + "label.other": "Outro", + "label.overview": "Resumo", + "label.owner": "Propietario/a", + "label.page": "Páxina", + "label.page-of": "Páxina {current} de {total}", + "label.page-views": "Vistas de páxinas", + "label.pageTitle": "Título da páxina", + "label.pages": "Páxinas", + "label.paid-ads": "Anuncios de pago", + "label.paid-search": "Busca de pago", + "label.paid-shopping": "Compra de pago", + "label.paid-social": "Social de pago", + "label.paid-video": "Vídeo de pago", + "label.password": "Contrasinal", + "label.path": "Ruta", + "label.paths": "Rutas", + "label.pixels": "Píxeles", + "label.powered-by": "Funciona grazas a {name}", + "label.previous": "Anterior", + "label.previous-period": "Periodo anterior", + "label.previous-year": "Ano anterior", + "label.profile": "Perfil", + "label.properties": "Propiedades", + "label.property": "Propiedade", + "label.queries": "Peticións", + "label.query": "Petición", + "label.query-parameters": "Parámetros da petición", + "label.realtime": "Agora mesmo", + "label.referral": "Referencia", + "label.referrer": "Orixe", + "label.referrers": "Orixes", + "label.refresh": "Actualizar", + "label.regenerate": "Rexenerar", + "label.region": "Rexión", + "label.regions": "Rexións", + "label.remaining": "Restante", + "label.remove": "Eliminar", + "label.remove-member": "Eliminar membro", + "label.reports": "Reportes", + "label.required": "Requerido", + "label.reset": "Restablecer", + "label.reset-website": "Para restablecer este sitio web, escriba {confirmation} na caixa de texto de embaixo para confirmar.", + "label.retention": "Retención", + "label.retention-description": "Mide a fidelidade dos usuarios ao teu sitio web seguindo a frecuencia coa que volven.", + "label.revenue": "Ingresos", + "label.revenue-description": "Consulta os teus ingresos ao longo do tempo.", + "label.role": "Rol", + "label.run-query": "Executar petición", + "label.save": "Gardar", + "label.screens": "Pantallas", + "label.search": "Buscar", + "label.select": "Seleccionar", + "label.select-date": "Seleccionar data", + "label.select-filter": "Seleccionar filtro", + "label.select-role": "Seleccionar rol", + "label.select-website": "Seleccionar sitio web", + "label.session": "Sesión", + "label.session-data": "Datos da sesión", + "label.sessions": "Sesións", + "label.settings": "Axustes", + "label.share": "Compartir", + "label.share-url": "Compartir URL", + "label.single-day": "Un só día", + "label.sms": "SMS", + "label.sources": "Fontes", + "label.start-step": "Start Step", + "label.steps": "Pasos", + "label.sum": "Suma", + "label.tablet": "Tableta", + "label.tag": "Etiqueta", + "label.tags": "Etiquetas", + "label.team": "Equipo", + "label.team-id": "ID do equipo", + "label.team-manager": "Xestor do equipo", + "label.team-member": "Membro do equipo", + "label.team-name": "Nome do equipo", + "label.team-owner": "Propietario do equipo", + "label.team-settings": "Axustes do equipo", + "label.team-view-only": "Equipo de só lectura", + "label.team-websites": "Sitios web do equipo", + "label.teams": "Equipos", + "label.terms": "Termos", + "label.theme": "Decorado", + "label.this-month": "Este mes", + "label.this-week": "Esta semana", + "label.this-year": "Este ano", + "label.timezone": "Zona horaria", + "label.title": "Título", + "label.today": "Hoxe", + "label.toggle-charts": "Activación das gráficas", + "label.total": "Total", + "label.total-records": "Rexistros totais", + "label.tracking-code": "Código de seguemento", + "label.transactions": "Transaccións", + "label.transfer": "Transferir", + "label.transfer-website": "Transferir sitio web", + "label.true": "Verdadeiro", + "label.type": "Tipo", + "label.unique": "Único", + "label.unique-visitors": "Visitas únicas", + "label.uniqueCustomers": "Clientes únicos", + "label.unknown": "Descoñecido", + "label.untitled": "Sen título", + "label.update": "Actualizar", + "label.user": "Usuario", + "label.username": "Identificador", + "label.users": "Usuarios", + "label.utm": "UTM", + "label.utm-description": "Segue as túas campañas a través dos parámetros UTM.", + "label.value": "Valor", + "label.view": "Vista", + "label.view-details": "Ver detalles", + "label.view-only": "Só lectura", + "label.views": "Visualizacións", + "label.views-per-visit": "Visualizacións por visita", + "label.visit-duration": "Tempo medio de visita", + "label.visitors": "Visitantes", + "label.visits": "Visitas", + "label.website": "Sitio web", + "label.website-id": "ID do sitio web", + "label.websites": "Sitios web", + "label.window": "Ventá", + "label.yesterday": "Onte", + "label.behavior": "Comportamento", + "message.action-confirmation": "Escribe {confirmation} na caixa de embaixo para confirmar.", + "message.active-users": "{x} actual {x, plural, one {visitante} other {visitantes}}", + "message.bad-request": "Bad request", + "message.collected-data": "Datos recopilados", + "message.confirm-delete": "Estás seguro/a de que queres eliminar {target}?", + "message.confirm-leave": "Estás seguro/a de que queres deixar {target}?", + "message.confirm-remove": "Estás seguro/a de que queres eliminar {target}?", + "message.confirm-reset": "Estás seguro/a de querer restablecer as estatísticas de {target}?", + "message.delete-team-warning": "Eliminar un equipo tamén eliminará tódolos sitios web do equipo.", + "message.delete-website-warning": "Tamén serán borrados tódolos datos asociados.", + "message.error": "Houbo un fallo.", + "message.event-log": "{event} en {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Ir aos axustes", + "message.incorrect-username-password": "Credenciais incorrectas.", + "message.invalid-domain": "Dominio non válido", + "message.min-password-length": "Lonxitude mínima de {n} caracteres", + "message.new-version-available": "Unha nova versión de Umami {version} está dispoñible!", + "message.no-data-available": "Sen datos dispoñibles.", + "message.no-event-data": "Sen datos de eventos dispoñibles.", + "message.no-match-password": "Non concordan os contrasinais", + "message.no-results-found": "Non se atoparon resultados.", + "message.no-team-websites": "Este equipo non ten ningún sitio web.", + "message.no-teams": "Non creaches ningún equipo.", + "message.no-users": "Non hai usuarios.", + "message.no-websites-configured": "Non tes sitios web configurados.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Páxina non atopada.", + "message.reset-website": "Para restablecer este sitio web, escriba {confirmation} na caixa de embaixo para confirmar.", + "message.reset-website-warning": "Vanse eliminar tódalas estatísticas deste sitio web, pero o código de seguimento permanecerá sen cambios.", + "message.saved": "Gardouse correctamente.", + "message.sever-error": "Server error", + "message.share-url": "Este é o URL da compartición pública de {target}.", + "message.team-already-member": "Xa es membro do equipo.", + "message.team-not-found": "Equipo non atopado.", + "message.team-websites-info": "Os sitios web poden ser vistos por calquera membro do equipo.", + "message.tracking-code": "Código de seguimento", + "message.transfer-team-website-to-user": "Transferir este sitio web á túa conta?", + "message.transfer-user-website-to-team": "Selecciona o equipo ao que transferir este sitio web.", + "message.transfer-website": "Transferir propiedade do sitio web á túa conta ou a outro equipo.", + "message.triggered-event": "Activou o evento", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Usuario eliminado.", + "message.viewed-page": "Páxina vista", + "message.visitor-log": "Visitante desde {country} usando {browser} en {os} {device}" +} diff --git a/src/lang/he-IL.json b/src/lang/he-IL.json new file mode 100644 index 0000000..2d115c8 --- /dev/null +++ b/src/lang/he-IL.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "קוד גישה", + "label.actions": "פעולות", + "label.activity": "יומן פעילות", + "label.add": "הוסף", + "label.add-board": "הוסף לוח", + "label.add-description": "הוסף תיאור", + "label.add-member": "הוסף חבר", + "label.add-step": "הוסף שלב", + "label.add-website": "הוספת אתר", + "label.admin": "מנהל", + "label.affiliate": "שותף", + "label.after": "אחרי", + "label.all": "הכל", + "label.all-time": "כל הזמנים", + "label.analytics": "אנליטיקה", + "label.apply": "החל", + "label.attribution": "שיוך", + "label.attribution-description": "צפה כיצד משתמשים מתקשרים עם השיווק שלך ומה מניע המרות.", + "label.average": "ממוצע", + "label.back": "חזרה", + "label.before": "לפני", + "label.boards": "לוחות", + "label.bounce-rate": "שיעור נטישה", + "label.breakdown": "פירוט", + "label.browser": "דפדפן", + "label.browsers": "דפדפנים", + "label.campaigns": "קמפיינים", + "label.cancel": "ביטול", + "label.change-password": "שינוי סיסמה", + "label.channels": "ערוצים", + "label.cities": "ערים", + "label.city": "עיר", + "label.clear-all": "נקה הכל", + "label.cohort": "קבוצה", + "label.compare": "השווה", + "label.compare-dates": "השווה תאריכים", + "label.confirm": "אשר", + "label.confirm-password": "אישור סיסמה", + "label.contains": "Contains", + "label.content": "תוכן", + "label.continue": "המשך", + "label.conversion": "המרה", + "label.conversion-rate": "שיעור המרה", + "label.conversion-step": "שלב המרה", + "label.count": "ספירה", + "label.countries": "מדינות", + "label.country": "מדינה", + "label.create": "צור", + "label.create-report": "צור דוח", + "label.create-team": "צור צוות", + "label.create-user": "צור משתמש", + "label.created": "נוצר", + "label.created-by": "נוצר על ידי", + "label.currency": "מטבע", + "label.current": "נוכחי", + "label.current-password": "סיסמה נוכחית", + "label.custom-range": "טווח מותאם", + "label.dashboard": "דשבורד", + "label.data": "נתונים", + "label.date": "תאריך", + "label.date-range": "טווח תאריכים", + "label.day": "יום", + "label.default-date-range": "טווח תאריכים בברירת מחדל", + "label.delete": "הסרה", + "label.delete-report": "מחק דוח", + "label.delete-team": "מחק צוות", + "label.delete-user": "מחק משתמש", + "label.delete-website": "הסרת אתר", + "label.description": "תיאור", + "label.desktop": "מחשב שולחני", + "label.details": "פרטים", + "label.device": "מכשיר", + "label.devices": "מכשירים", + "label.direct": "ישיר", + "label.dismiss": "שיחרור", + "label.distinct-id": "מזהה ייחודי", + "label.does-not-contain": "לא מכיל", + "label.does-not-include": "לא כולל", + "label.doest-not-exist": "לא קיים", + "label.domain": "דומיין", + "label.dropoff": "עזיבה", + "label.edit": "עריכה", + "label.edit-dashboard": "ערוך לוח מחוונים", + "label.edit-member": "ערוך חבר", + "label.email": "אימייל", + "label.enable-share-url": "הפעלת URL שיתוף", + "label.end-step": "שלב סיום", + "label.entry": "כתובת כניסה", + "label.event": "אירוע", + "label.event-data": "נתוני אירוע", + "label.event-name": "שם האירוע", + "label.events": "אירועים", + "label.exists": "קיים", + "label.exit": "כתובת יציאה", + "label.false": "שקר", + "label.field": "שדה", + "label.fields": "שדות", + "label.filter": "Filter", + "label.filter-combined": "משותף", + "label.filter-raw": "גולמי", + "label.filters": "מסננים", + "label.first-click": "קליק ראשון", + "label.first-seen": "נראה לראשונה", + "label.funnel": "משפך", + "label.funnel-description": "הבן את שיעור ההמרה והעזיבה של המשתמשים.", + "label.funnels": "משפכים", + "label.goal": "מטרה", + "label.goals": "מטרות", + "label.goals-description": "עקוב אחרי המטרות שלך לצפיות בדף ואירועים.", + "label.greater-than": "גדול מ-", + "label.greater-than-equals": "גדול או שווה ל-", + "label.grouped": "מקובץ", + "label.hostname": "שם מארח", + "label.includes": "כולל", + "label.insight": "תובנה", + "label.insights": "תובנות", + "label.insights-description": "צלול עמוק יותר לנתונים שלך באמצעות פילוחים ומסננים.", + "label.is": "הוא", + "label.is-false": "הוא שקר", + "label.is-not": "אינו", + "label.is-not-set": "לא הוגדר", + "label.is-set": "הוגדר", + "label.is-true": "הוא אמת", + "label.join": "הצטרף", + "label.join-team": "הצטרף לצוות", + "label.journey": "מסע", + "label.journey-description": "הבן כיצד משתמשים מנווטים באתר שלך.", + "label.journeys": "מסעות", + "label.language": "Language", + "label.languages": "Languages", + "label.laptop": "לפטופ", + "label.last-click": "קליק אחרון", + "label.last-days": "{x} ימים אחרונים", + "label.last-hours": "{x} שעות אחרונות", + "label.last-months": "{x} חודשים אחרונים", + "label.last-seen": "נראה לאחרונה", + "label.leave": "עזוב", + "label.leave-team": "עזוב צוות", + "label.less-than": "פחות מ-", + "label.less-than-equals": "פחות או שווה ל-", + "label.links": "קישורים", + "label.login": "התחברות", + "label.logout": "התנתקות", + "label.manage": "נהל", + "label.manager": "מנהל", + "label.max": "מקסימום", + "label.maximize": "הרחב", + "label.medium": "בינוני", + "label.member": "חבר", + "label.members": "חברים", + "label.min": "מינימום", + "label.mobile": "מובייל", + "label.model": "Model", + "label.more": "עוד", + "label.my-account": "החשבון שלי", + "label.my-websites": "האתרים שלי", + "label.name": "שם", + "label.new-password": "סיסמה חדשה", + "label.none": "ללא", + "label.number-of-records": "{x} {x, plural, one {רשומה} other {רשומות}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "חיפוש אורגני", + "label.organic-shopping": "קניות אורגניות", + "label.organic-social": "רשת חברתית אורגנית", + "label.organic-video": "וידאו אורגני", + "label.os": "OS", + "label.other": "אחר", + "label.overview": "סקירה כללית", + "label.owner": "בעלים", + "label.page": "דף", + "label.page-of": "דף {current} מתוך {total}", + "label.page-views": "צפיות בדפים", + "label.pageTitle": "Page title", + "label.pages": "דפים", + "label.paid-ads": "מודעות בתשלום", + "label.paid-search": "חיפוש בתשלום", + "label.paid-shopping": "קניות בתשלום", + "label.paid-social": "רשת חברתית בתשלום", + "label.paid-video": "וידאו בתשלום", + "label.password": "סיסמה", + "label.path": "נתיב", + "label.paths": "נתיבים", + "label.pixels": "פיקסלים", + "label.powered-by": "Powered by {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "פרופיל", + "label.properties": "מאפיינים", + "label.property": "מאפיין", + "label.queries": "שאילתות", + "label.query": "שאילתה", + "label.query-parameters": "פרמטרי שאילתה", + "label.realtime": "זמן אמת", + "label.referral": "הפניה", + "label.referrer": "Referrer", + "label.referrers": "מפנים", + "label.refresh": "רענון", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "נותר", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "נדרש", + "label.reset": "איפוס", + "label.reset-website": "Reset statistics", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "הכנסה", + "label.revenue-description": "בדוק את ההכנסות שלך לאורך זמן.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "שמירה", + "label.screens": "מסכים", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "בחר מסנן", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "סשן", + "label.session-data": "נתוני סשן", + "label.sessions": "Sessions", + "label.settings": "הגדרות", + "label.share": "שתף", + "label.share-url": "שיתוף URL", + "label.single-day": "יום בודד", + "label.sms": "SMS", + "label.sources": "מקורות", + "label.start-step": "שלב התחלה", + "label.steps": "שלבים", + "label.sum": "Sum", + "label.tablet": "טאבלט", + "label.tag": "תגית", + "label.tags": "תגיות", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "הגדרות צוות", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "תנאים", + "label.theme": "Theme", + "label.this-month": "החודש", + "label.this-week": "השבוע", + "label.this-year": "השנה", + "label.timezone": "אזור זמן", + "label.title": "Title", + "label.today": "היום", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "קוד מעקב", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "מבקרים ייחודיים", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "לא ידוע", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "שם משתמש", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "פרטים נוספים", + "label.behavior": "התנהגות", + "label.view-only": "View only", + "label.views": "צפיות", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "זמן ביקור ממוצע", + "label.visitors": "מבקרים", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "אתרים", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} נוכחיים {x, plural, one {מבקר} other {מבקרים}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "האם באמת למחוק את {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "כל המידע המקושר יימחק", + "message.error": "משהו השתבש", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "להדרותג", + "message.incorrect-username-password": "שם משתמש או סיסמה לא נכונים", + "message.invalid-domain": "דומיין לא תקין", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "אין מידע זמין", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "סיסמאות לא תואמות", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "לא מוגדרים אתרים", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "דף לא נמצא", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.", + "message.saved": "נשמר בהצלחה", + "message.sever-error": "Server error", + "message.share-url": "זהו URL ציבורי עבור {target}", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "קוד מעקב", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "מבקר ממדינת {country} משתמבש בדפדפן {browser} ב-{os} {device}" +} diff --git a/src/lang/hi-IN.json b/src/lang/hi-IN.json new file mode 100644 index 0000000..54cac30 --- /dev/null +++ b/src/lang/hi-IN.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "पहुंच कोड", + "label.actions": "कार्य", + "label.activity": "गतिविधि लॉग", + "label.add": "जोडो", + "label.add-board": "बोर्ड जोड़ें", + "label.add-description": "विवरण लिखें", + "label.add-member": "सदस्य जोड़ें", + "label.add-step": "चरण जोड़ें", + "label.add-website": "वेबसाइट", + "label.admin": "प्रशासक", + "label.affiliate": "संबद्ध", + "label.after": "बाद में", + "label.all": "सब", + "label.all-time": "सभी समय", + "label.analytics": "विश्लेषण", + "label.apply": "लागू करें", + "label.attribution": "अर्पण", + "label.attribution-description": "देखें कि उपयोगकर्ता आपके विपणन के साथ कैसे जुड़ते हैं और क्या रूपांतरण को प्रेरित करता है।", + "label.average": "औसत", + "label.back": "पीछे", + "label.before": "पहले", + "label.behavior": "व्यवहार", + "label.boards": "बोर्ड्स", + "label.bounce-rate": "उछाल दर", + "label.breakdown": "विभाजन", + "label.browser": "ब्राउज़र", + "label.browsers": "वेब ब्राउज़र", + "label.campaigns": "अभियान", + "label.cancel": "रद्द करें", + "label.change-password": "पासवर्ड बदलें", + "label.channels": "चैनल", + "label.cities": "शहर", + "label.city": "शहर", + "label.clear-all": "सभी साफ करें", + "label.cohort": "समूह", + "label.compare": "तुलना करें", + "label.compare-dates": "तिथियों की तुलना करें", + "label.confirm": "पुष्टि करें", + "label.confirm-password": "पासवर्ड की पुष्टि कीजिये", + "label.contains": "शामिल है", + "label.content": "सामग्री", + "label.continue": "जारी रखें", + "label.conversion": "रूपांतरण", + "label.conversion-rate": "रूपांतरण दर", + "label.conversion-step": "रूपांतरण चरण", + "label.count": "गिनती", + "label.countries": "देश", + "label.country": "देश", + "label.create": "बनाएँ", + "label.create-report": "रिपोर्ट बनाएं", + "label.create-team": "टीम बनाएं", + "label.create-user": "उपयोगकर्ता बनाएं", + "label.created": "बनाया गया", + "label.created-by": "द्वारा बनाया गया", + "label.currency": "मुद्रा", + "label.current": "वर्तमान", + "label.current-password": "वर्तमान पासवर्ड", + "label.custom-range": "कस्टम रेंज", + "label.dashboard": "नियंत्रण-पट्ट", + "label.data": "डेटा", + "label.date": "तिथि", + "label.date-range": "तिथि सीमा", + "label.day": "दिन", + "label.default-date-range": "डिफ़ॉल्ट तिथि सीमा", + "label.delete": "खाता हटाएं", + "label.delete-report": "रिपोर्ट हटाएं", + "label.delete-team": "टीम हटाएं", + "label.delete-user": "उपयोगकर्ता हटाएं", + "label.delete-website": "वेबसाइट हटाएं", + "label.description": "विवरण", + "label.desktop": "डेस्कटॉप", + "label.details": "विवरण", + "label.device": "डिवाइस", + "label.devices": "उपकरण", + "label.direct": "प्रत्यक्ष", + "label.dismiss": "खारिज कीजिये", + "label.distinct-id": "अद्वितीय आईडी", + "label.does-not-contain": "शामिल नहीं है", + "label.does-not-include": "शामिल नहीं है", + "label.doest-not-exist": "मौजूद नहीं है", + "label.domain": "डोमेन", + "label.dropoff": "Dropoff", + "label.edit": "संपादित करें", + "label.edit-dashboard": "डैशबोर्ड संपादित करें", + "label.edit-member": "सदस्य संपादित करें", + "label.email": "ईमेल", + "label.enable-share-url": "शेयर URL सक्षम करें", + "label.end-step": "अंतिम चरण", + "label.entry": "प्रवेश URL", + "label.event": "घटना", + "label.event-data": "घटना डेटा", + "label.event-name": "घटना नाम", + "label.events": "स्पर्धाएँ", + "label.exists": "मौजूद है", + "label.exit": "निकास URL", + "label.false": "गलत", + "label.field": "फ़ील्ड", + "label.fields": "फ़ील्ड्स", + "label.filter": "फ़िल्टर", + "label.filter-combined": "संयुक्त", + "label.filter-raw": "रॉ", + "label.filters": "फ़िल्टर", + "label.first-click": "पहला क्लिक", + "label.first-seen": "पहली बार देखा गया", + "label.funnel": "फनल", + "label.funnel-description": "उपयोगकर्ताओं की रूपांतरण और ड्रॉप-ऑफ दर को समझें।", + "label.funnels": "फनल्स", + "label.goal": "लक्ष्य", + "label.goals": "लक्ष्य", + "label.goals-description": "पृष्ठदृश्यों और घटनाओं के लिए अपने लक्ष्यों को ट्रैक करें।", + "label.greater-than": "से अधिक", + "label.greater-than-equals": "से अधिक या बराबर", + "label.grouped": "समूहित", + "label.hostname": "होस्टनाम", + "label.includes": "शामिल है", + "label.insight": "अंतर्दृष्टि", + "label.insights": "अंतर्दृष्टियाँ", + "label.insights-description": "सेगमेंट और फ़िल्टर का उपयोग करके अपने डेटा में गहराई से जाएं।", + "label.is": "है", + "label.is-false": "गलत है", + "label.is-not": "नहीं है", + "label.is-not-set": "सेट नहीं है", + "label.is-set": "सेट है", + "label.is-true": "सही है", + "label.join": "शामिल हों", + "label.join-team": "टीम में शामिल हों", + "label.journey": "यात्रा", + "label.journey-description": "समझें कि उपयोगकर्ता आपकी वेबसाइट पर कैसे नेविगेट करते हैं।", + "label.journeys": "यात्राएँ", + "label.language": "भाषा", + "label.languages": "भाषाएँ", + "label.laptop": "लैपटॉप", + "label.last-click": "अंतिम क्लिक", + "label.last-days": "पिछले {x} दिन", + "label.last-hours": "पिछले {x} घंटे", + "label.last-months": "पिछले {x} महीने", + "label.last-seen": "अंतिम बार देखा गया", + "label.leave": "छोड़ें", + "label.leave-team": "टीम छोड़ें", + "label.less-than": "से कम", + "label.less-than-equals": "से कम या बराबर", + "label.links": "लिंक", + "label.login": "लॉग इन", + "label.logout": "लॉग आउट", + "label.manage": "प्रबंधित करें", + "label.manager": "प्रबंधक", + "label.max": "अधिकतम", + "label.maximize": "विस्तार करें", + "label.medium": "मध्यम", + "label.member": "सदस्य", + "label.members": "सदस्यगण", + "label.min": "न्यूनतम", + "label.mobile": "मोबाइल फोन", + "label.model": "मॉडल", + "label.more": "और", + "label.my-account": "मेरा खाता", + "label.my-websites": "मेरी वेबसाइट्स", + "label.name": "नाम", + "label.new-password": "नया पासवर्ड", + "label.none": "कोई नहीं", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "ऑर्गेनिक खोज", + "label.organic-shopping": "ऑर्गेनिक खरीदारी", + "label.organic-social": "ऑर्गेनिक सोशल", + "label.organic-video": "ऑर्गेनिक वीडियो", + "label.os": "OS", + "label.other": "अन्य", + "label.overview": "सारांश", + "label.owner": "मालिक", + "label.page": "पृष्ठ", + "label.page-of": "पृष्ठ {current} का {total}", + "label.page-views": "पृष्ठ दृश्य", + "label.pageTitle": "पृष्ठ शीर्षक", + "label.pages": "पृष्ठों", + "label.paid-ads": "पेड विज्ञापन", + "label.paid-search": "पेड खोज", + "label.paid-shopping": "पेड खरीदारी", + "label.paid-social": "पेड सोशल", + "label.paid-video": "पेड वीडियो", + "label.password": "पासवर्ड", + "label.path": "पथ", + "label.paths": "पथ", + "label.pixels": "पिक्सेल", + "label.powered-by": "{name} द्वारा संचालित", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "प्रोफ़ाइल", + "label.properties": "गुण", + "label.property": "गुण", + "label.queries": "प्रश्न", + "label.query": "प्रश्न", + "label.query-parameters": "प्रश्न पैरामीटर", + "label.realtime": "वास्तव काल", + "label.referral": "संदर्भ", + "label.referrer": "संदर्भकर्ता", + "label.referrers": "सन्दर्भदाता", + "label.refresh": "रिफ्रेश", + "label.regenerate": "पुनः उत्पन्न करें", + "label.region": "क्षेत्र", + "label.regions": "क्षेत्र", + "label.remaining": "शेष", + "label.remove": "हटाएं", + "label.remove-member": "सदस्य हटाएं", + "label.reports": "रिपोर्ट्स", + "label.required": "अपेक्षित", + "label.reset": "रीसेट", + "label.reset-website": "आँकड़े रीसेट करें", + "label.retention": "पुनः आगमन", + "label.retention-description": "यह मापें कि उपयोगकर्ता कितनी बार आपकी वेबसाइट पर लौटते हैं।", + "label.revenue": "राजस्व", + "label.revenue-description": "समय के साथ अपने राजस्व को देखें।", + "label.role": "भूमिका", + "label.run-query": "प्रश्न चलाएँ", + "label.save": "सहेजें", + "label.screens": "स्क्रीन", + "label.search": "खोजें", + "label.select": "चुनें", + "label.select-date": "तिथि चुनें", + "label.select-filter": "फ़िल्टर चुनें", + "label.select-role": "भूमिका चुनें", + "label.select-website": "वेबसाइट चुनें", + "label.session": "सत्र", + "label.session-data": "सत्र डेटा", + "label.sessions": "सत्र", + "label.settings": "समायोजन", + "label.share": "साझा करें", + "label.share-url": "यूआरएल साझा करें", + "label.single-day": "एक दिन", + "label.sms": "SMS", + "label.sources": "स्रोत", + "label.start-step": "प्रारंभिक चरण", + "label.steps": "चरण", + "label.sum": "योग", + "label.tablet": "टैबलेट", + "label.tag": "टैग", + "label.tags": "टैग्स", + "label.team": "टीम", + "label.team-id": "टीम आईडी", + "label.team-manager": "टीम प्रबंधक", + "label.team-member": "टीम सदस्य", + "label.team-name": "टीम नाम", + "label.team-owner": "टीम मालिक", + "label.team-settings": "टीम सेटिंग्स", + "label.team-view-only": "केवल टीम देखें", + "label.team-websites": "टीम वेबसाइट्स", + "label.teams": "टीमें", + "label.terms": "शर्तें", + "label.theme": "थीम", + "label.this-month": "इस महीने", + "label.this-week": "इस सप्ताह", + "label.this-year": "इस साल", + "label.timezone": "समय क्षेत्र", + "label.title": "Title", + "label.today": "आज", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "ट्रैकिंग कोड", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "अद्वितीय आगंतुकों", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "अज्ञात", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "उपयोगकर्ता नाम", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "विवरण देखें", + "label.view-only": "View only", + "label.views": "दृश्य", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "औसत दृश्य समय", + "label.visitors": "आगंतुकों", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "वेबसाइटों", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} मौजूद {x, plural, one {आगंतुक} other {आगंतुकों}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "क्या आप वाकई में {target} हटाना चाहते हैं?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "सभी संबद्ध डेटा को भी हटा दिया जाएगा।", + "message.error": "कुछ गलत हो गया।", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "समायोजन में जाइए", + "message.incorrect-username-password": "ग़लत उपयोगकर्ता नाम / पासवर्ड।", + "message.invalid-domain": "अमान्य डोमेन", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "कोई डेटा उपलब्ध नहीं है।", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "पासवर्ड मेल नहीं खाते", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "आपके पास कोई वेबसाइट कॉन्फ़िगर नहीं है।", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "पृष्ठ नहीं मिला।", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.", + "message.saved": "सफलतापूर्वक संचित कर लिया गया है।", + "message.sever-error": "Server error", + "message.share-url": "यह {target} के लिए सार्वजनिक रूप से साझा किया गया URL है।", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "ट्रैकिंग कोड", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "{country} का आगंतुक, जो {browser} का उपयोग करता है, {os} यन्त्र पर" +} diff --git a/src/lang/hr-HR.json b/src/lang/hr-HR.json new file mode 100644 index 0000000..141ad3f --- /dev/null +++ b/src/lang/hr-HR.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Pristupni kod", + "label.actions": "Akcije", + "label.activity": "Dnevnik aktivnosti", + "label.add": "Dodaj", + "label.add-board": "Dodaj ploču", + "label.add-description": "Dodaj opis", + "label.add-member": "Dodaj člana", + "label.add-step": "Dodaj korak", + "label.add-website": "Dodaj web stranicu", + "label.admin": "Administrator", + "label.affiliate": "Partner", + "label.after": "Nakon", + "label.all": "Sve", + "label.all-time": "Svo vrijeme", + "label.analytics": "Analitika", + "label.apply": "Primijeni", + "label.attribution": "Atribucija", + "label.attribution-description": "Pogledajte kako korisnici komuniciraju s vašim marketingom i što dovodi do konverzija.", + "label.average": "Prosjek", + "label.back": "Natrag ", + "label.before": "Prije", + "label.behavior": "Ponašanje", + "label.boards": "Ploče", + "label.bounce-rate": "Stopa napuštanja", + "label.breakdown": "Raspad", + "label.browser": "Preglednik", + "label.browsers": "Preglednici", + "label.campaigns": "Kampanje", + "label.cancel": "Odustani", + "label.change-password": "Promijeni lozinku", + "label.channels": "Kanali", + "label.cities": "Gradovi", + "label.city": "Grad", + "label.clear-all": "Očisti sve", + "label.cohort": "Kohorta", + "label.compare": "Usporedi", + "label.compare-dates": "Usporedi datume", + "label.confirm": "Potvrdi", + "label.confirm-password": "Potvrdi lozinku", + "label.contains": "Contains", + "label.content": "Sadržaj", + "label.continue": "Nastavi", + "label.conversion": "Konverzija", + "label.conversion-rate": "Stopa konverzije", + "label.conversion-step": "Korak konverzije", + "label.count": "Broj", + "label.countries": "Countries", + "label.country": "Država", + "label.create": "Kreiraj", + "label.create-report": "Kreiraj izvještaj", + "label.create-team": "Kreiraj tim", + "label.create-user": "Kreiraj korisnika", + "label.created": "Kreirano", + "label.created-by": "Kreirao", + "label.currency": "Valuta", + "label.current": "Trenutno", + "label.current-password": "Trenutna lozinka", + "label.custom-range": "Prilagođeni raspon", + "label.dashboard": "Nadzorna ploča", + "label.data": "Podaci", + "label.date": "Datum", + "label.date-range": "Raspon datuma", + "label.day": "Dan", + "label.default-date-range": "Zadani datumski raspon", + "label.delete": "Obriši", + "label.delete-report": "Obriši izvještaj", + "label.delete-team": "Obriši tim", + "label.delete-user": "Obriši korisnika", + "label.delete-website": "Obriši web stranicu", + "label.description": "Opis", + "label.desktop": "Stolno računalo", + "label.details": "Detalji", + "label.device": "Uređaj", + "label.devices": "Uređaji", + "label.direct": "Direktno", + "label.dismiss": "Odbaci", + "label.distinct-id": "Jedinstveni ID", + "label.does-not-contain": "Ne sadrži", + "label.does-not-include": "Ne uključuje", + "label.doest-not-exist": "Ne postoji", + "label.domain": "Domena", + "label.dropoff": "Odlazak", + "label.edit": "Uredi", + "label.edit-dashboard": "Uredi nadzornu ploču", + "label.edit-member": "Uredi člana", + "label.email": "E-mail", + "label.enable-share-url": "Omogući dijeljenje poveznice", + "label.end-step": "Završni korak", + "label.entry": "Ulazni URL", + "label.event": "Događaj", + "label.event-data": "Podaci događaja", + "label.event-name": "Naziv događaja", + "label.events": "Events", + "label.exists": "Postoji", + "label.exit": "Izlazni URL", + "label.false": "Netočno", + "label.field": "Polje", + "label.fields": "Polja", + "label.filter": "Filter", + "label.filter-combined": "Combined", + "label.filter-raw": "Raw", + "label.filters": "Filteri", + "label.first-click": "Prvi klik", + "label.first-seen": "Prvi put viđeno", + "label.funnel": "Lijevak", + "label.funnel-description": "Razumite stopu konverzije i odlaska korisnika.", + "label.funnels": "Ljevci", + "label.goal": "Cilj", + "label.goals": "Ciljevi", + "label.goals-description": "Pratite svoje ciljeve za prikaze stranica i događaje.", + "label.greater-than": "Veće od", + "label.greater-than-equals": "Veće ili jednako", + "label.grouped": "Grupirano", + "label.hostname": "Naziv hosta", + "label.includes": "Uključuje", + "label.insight": "Uvid", + "label.insights": "Uvidi", + "label.insights-description": "Dublje analizirajte svoje podatke pomoću segmenata i filtera.", + "label.is": "Je", + "label.is-false": "Je netočno", + "label.is-not": "Nije", + "label.is-not-set": "Nije postavljeno", + "label.is-set": "Postavljeno", + "label.is-true": "Je točno", + "label.join": "Pridruži se", + "label.join-team": "Pridruži se timu", + "label.journey": "Putovanje", + "label.journey-description": "Razumite kako korisnici navigiraju vašom web stranicom.", + "label.journeys": "Putovanja", + "label.language": "Jezik", + "label.languages": "Languages", + "label.laptop": "Laptop", + "label.last-click": "Zadnji klik", + "label.last-days": "Zadnjih {x} dana", + "label.last-hours": "Zadnjih {x} sati", + "label.last-months": "Zadnjih {x} mjeseci", + "label.last-seen": "Zadnji put viđeno", + "label.leave": "Napusti", + "label.leave-team": "Napusti tim", + "label.less-than": "Manje od", + "label.less-than-equals": "Manje ili jednako", + "label.links": "Poveznice", + "label.login": "Prijava", + "label.logout": "Odjava", + "label.manage": "Upravljaj", + "label.manager": "Upravitelj", + "label.max": "Maksimum", + "label.maximize": "Proširi", + "label.medium": "Srednje", + "label.member": "Član", + "label.members": "Članovi", + "label.min": "Minimum", + "label.mobile": "Mobile", + "label.model": "Model", + "label.more": "Više", + "label.my-account": "Moj račun", + "label.my-websites": "Moje web stranice", + "label.name": "Ime", + "label.new-password": "Nova lozinka", + "label.none": "Nijedan", + "label.number-of-records": "{x} {x, plural, one {zapis} other {zapisa}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organsko pretraživanje", + "label.organic-shopping": "Organska kupovina", + "label.organic-social": "Organska društvena mreža", + "label.organic-video": "Organski videozapis", + "label.os": "OS", + "label.other": "Ostalo", + "label.overview": "Pregled", + "label.owner": "Vlasnik", + "label.page": "Stranica", + "label.page-of": "Stranica {current} od {total}", + "label.page-views": "Page views", + "label.pageTitle": "Page title", + "label.pages": "Pages", + "label.paid-ads": "Plaćeni oglasi", + "label.paid-search": "Plaćeno pretraživanje", + "label.paid-shopping": "Plaćena kupovina", + "label.paid-social": "Plaćena društvena mreža", + "label.paid-video": "Plaćeni videozapis", + "label.password": "Lozinka", + "label.path": "Putanja", + "label.paths": "Putanje", + "label.pixels": "Pikseli", + "label.powered-by": "Powered by {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Profil", + "label.properties": "Svojstva", + "label.property": "Svojstvo", + "label.queries": "Upiti", + "label.query": "Upit", + "label.query-parameters": "Parametri upita", + "label.realtime": "Stvarno vrijeme", + "label.referral": "Preporuka", + "label.referrer": "Referrer", + "label.referrers": "Referrers", + "label.refresh": "Osvježi", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Preostalo", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "Potrebna", + "label.reset": "Resetirati", + "label.reset-website": "Resetirati web stranicu", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Prihod", + "label.revenue-description": "Pogledajte svoj prihod tijekom vremena.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Spremi", + "label.screens": "Ekrani", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Odaberi filter", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Sesija", + "label.session-data": "Podaci sesije", + "label.sessions": "Sessions", + "label.settings": "Postavke", + "label.share": "Podijeli", + "label.share-url": "Podijeli poveznicu", + "label.single-day": "Jedan dan", + "label.sms": "SMS", + "label.sources": "Izvori", + "label.start-step": "Početni korak", + "label.steps": "Koraci", + "label.sum": "Sum", + "label.tablet": "Tablet", + "label.tag": "Oznaka", + "label.tags": "Oznake", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Postavke tima", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Pojmovi", + "label.theme": "Tema", + "label.this-month": "Ovaj mjesec", + "label.this-week": "Ovaj tjedan", + "label.this-year": "Ova godina", + "label.timezone": "Vremenska zona", + "label.title": "Title", + "label.today": "Danas", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Kod za praćenje", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Unique visitors", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Nepoznato", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Korisničko ime", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Pogledaj detalje", + "label.view-only": "View only", + "label.views": "Views", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Visit duration", + "label.visitors": "Visitors", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Web stranice", + "label.window": "Window", + "label.yesterday": "Jučer", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} Trenutno {x, plural, one {posjetitelj} other {posjetitelja}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Jeste li sigurni da želite obrisati {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Jeste li sigurni da želite resetirati {target}'s statistiku?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "All website data will be deleted.", + "message.error": "Something went wrong.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Idi u postavke", + "message.incorrect-username-password": "Neispravno korisničke ime/lozinka.", + "message.invalid-domain": "Invalid domain. Do not include http/https.", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Nema dostupnih podataka.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Passwords do not match.", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "You do not have any websites configured.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Stranica nije pronađena.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your settings will remain intact.", + "message.saved": "Saved.", + "message.sever-error": "Server error", + "message.share-url": "Ovo je javno dijeljena poveznica za {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "To track stats for this website, place the following code in the <head>...</head> section of your HTML.", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Visitor from {country} using {browser} on {os} {device}" +} diff --git a/src/lang/hu-HU.json b/src/lang/hu-HU.json new file mode 100644 index 0000000..1666b7a --- /dev/null +++ b/src/lang/hu-HU.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Hozzáférési kód", + "label.actions": "Műveletek", + "label.activity": "Tevékenységnapló", + "label.add": "Hozzáadás", + "label.add-board": "Tábla hozzáadása", + "label.add-description": "Leírás hozzáadása", + "label.add-member": "Tag hozzáadása", + "label.add-step": "Lépés hozzáadása", + "label.add-website": "Weboldal hozzáadása", + "label.admin": "Adminisztrátor", + "label.affiliate": "Partner", + "label.after": "Után", + "label.all": "Összes", + "label.all-time": "Minden időszak", + "label.analytics": "Analitika", + "label.apply": "Alkalmaz", + "label.attribution": "Attribúció", + "label.attribution-description": "Nézze meg, hogyan lépnek kapcsolatba a felhasználók a marketingjével, és mi vezet konverzióhoz.", + "label.average": "Átlag", + "label.back": "Vissza", + "label.before": "Előtt", + "label.behavior": "Viselkedés", + "label.boards": "Táblák", + "label.bounce-rate": "Visszafordulási arány", + "label.breakdown": "Bontás", + "label.browser": "Böngésző", + "label.browsers": "Böngészők", + "label.campaigns": "Kampányok", + "label.cancel": "Mégsem", + "label.change-password": "Jelszó módosítása", + "label.channels": "Csatornák", + "label.cities": "Városok", + "label.city": "Város", + "label.clear-all": "Összes törlése", + "label.cohort": "Kohorsz", + "label.compare": "Összehasonlít", + "label.compare-dates": "Dátumok összehasonlítása", + "label.confirm": "Megerősít", + "label.confirm-password": "Jelszó megerősítése", + "label.contains": "Contains", + "label.content": "Tartalom", + "label.continue": "Folytatás", + "label.conversion": "Konverzió", + "label.conversion-rate": "Konverziós arány", + "label.conversion-step": "Konverziós lépés", + "label.count": "Darabszám", + "label.countries": "Országok", + "label.country": "Ország", + "label.create": "Létrehozás", + "label.create-report": "Jelentés létrehozása", + "label.create-team": "Csapat létrehozása", + "label.create-user": "Felhasználó létrehozása", + "label.created": "Létrehozva", + "label.created-by": "Létrehozta", + "label.currency": "Pénznem", + "label.current": "Jelenlegi", + "label.current-password": "Jelenlegi jelszó", + "label.custom-range": "Egyedi tartomány", + "label.dashboard": "Áttekintés", + "label.data": "Adat", + "label.date": "Dátum", + "label.date-range": "Időintervallum", + "label.day": "Nap", + "label.default-date-range": "Alapértelmezett időintervallum", + "label.delete": "Eltávolítás", + "label.delete-report": "Jelentés törlése", + "label.delete-team": "Csapat törlése", + "label.delete-user": "Felhasználó törlése", + "label.delete-website": "Weboldal eltávolítása", + "label.description": "Leírás", + "label.desktop": "Asztali számítógép", + "label.details": "Részletek", + "label.device": "Eszköz", + "label.devices": "Eszközök", + "label.direct": "Közvetlen", + "label.dismiss": "Mellőzés", + "label.distinct-id": "Egyedi azonosító", + "label.does-not-contain": "Nem tartalmazza", + "label.does-not-include": "Nem tartalmazza", + "label.doest-not-exist": "Nem létezik", + "label.domain": "Domain", + "label.dropoff": "Lemorzsolódás", + "label.edit": "Módosítás", + "label.edit-dashboard": "Irányítópult szerkesztése", + "label.edit-member": "Tag szerkesztése", + "label.email": "E-mail", + "label.enable-share-url": "URL-megosztás engedélyezése", + "label.end-step": "Befejező lépés", + "label.entry": "Belépési URL", + "label.event": "Esemény", + "label.event-data": "Eseményadatok", + "label.event-name": "Esemény neve", + "label.events": "Események", + "label.exists": "Létezik", + "label.exit": "Kilépési URL", + "label.false": "Hamis", + "label.field": "Mező", + "label.fields": "Mezők", + "label.filter": "Filter", + "label.filter-combined": "Összevont", + "label.filter-raw": "Nyers", + "label.filters": "Szűrők", + "label.first-click": "Első kattintás", + "label.first-seen": "Első megtekintés", + "label.funnel": "Tölcsér", + "label.funnel-description": "Értse meg a felhasználók konverziós és lemorzsolódási arányát.", + "label.funnels": "Tölcsérek", + "label.goal": "Cél", + "label.goals": "Célok", + "label.goals-description": "Kövesse nyomon a céljait oldalmegtekintések és események alapján.", + "label.greater-than": "Nagyobb mint", + "label.greater-than-equals": "Nagyobb vagy egyenlő", + "label.grouped": "Csoportosítva", + "label.hostname": "Hosztnév", + "label.includes": "Tartalmazza", + "label.insight": "Betekintés", + "label.insights": "Betekintések", + "label.insights-description": "Merüljön el mélyebben az adataiban szegmensek és szűrők használatával.", + "label.is": "Az", + "label.is-false": "Hamis", + "label.is-not": "Nem az", + "label.is-not-set": "Nincs beállítva", + "label.is-set": "Beállítva", + "label.is-true": "Igaz", + "label.join": "Csatlakozás", + "label.join-team": "Csatlakozás a csapathoz", + "label.journey": "Út", + "label.journey-description": "Értse meg, hogyan navigálnak a felhasználók a weboldalán.", + "label.journeys": "Utak", + "label.language": "Language", + "label.languages": "Languages", + "label.laptop": "Laptop", + "label.last-click": "Utolsó kattintás", + "label.last-days": "Legutóbbi {x} nap", + "label.last-hours": "Legutóbbi {x} óra", + "label.last-months": "Utolsó {x} hónap", + "label.last-seen": "Utoljára látva", + "label.leave": "Kilépés", + "label.leave-team": "Csapat elhagyása", + "label.less-than": "Kevesebb mint", + "label.less-than-equals": "Kevesebb vagy egyenlő", + "label.links": "Linkek", + "label.login": "Bejelentkezés", + "label.logout": "Kijelentkezés", + "label.manage": "Kezelés", + "label.manager": "Menedzser", + "label.max": "Maximum", + "label.maximize": "Kibontás", + "label.medium": "Közepes", + "label.member": "Tag", + "label.members": "Tagok", + "label.min": "Minimum", + "label.mobile": "Telefon", + "label.model": "Model", + "label.more": "Bővebben", + "label.my-account": "Saját fiók", + "label.my-websites": "Saját weboldalak", + "label.name": "Név", + "label.new-password": "Új jelszó", + "label.none": "Nincs", + "label.number-of-records": "{x} {x, plural, one {rekord} other {rekord}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organikus keresés", + "label.organic-shopping": "Organikus vásárlás", + "label.organic-social": "Organikus közösségi", + "label.organic-video": "Organikus videó", + "label.os": "OS", + "label.other": "Egyéb", + "label.overview": "Áttekintés", + "label.owner": "Tulajdonos", + "label.page": "Oldal", + "label.page-of": "Oldal {current} / {total}", + "label.page-views": "Oldalmegtekintések", + "label.pageTitle": "Page title", + "label.pages": "Oldalak", + "label.paid-ads": "Fizetett hirdetések", + "label.paid-search": "Fizetett keresés", + "label.paid-shopping": "Fizetett vásárlás", + "label.paid-social": "Fizetett közösségi", + "label.paid-video": "Fizetett videó", + "label.password": "Jelszó", + "label.path": "Útvonal", + "label.paths": "Útvonalak", + "label.pixels": "Pixelek", + "label.powered-by": "Működteti az {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Profil", + "label.properties": "Tulajdonságok", + "label.property": "Tulajdonság", + "label.queries": "Lekérdezések", + "label.query": "Lekérdezés", + "label.query-parameters": "Lekérdezési paraméterek", + "label.realtime": "Valós idejű", + "label.referral": "Hivatkozás", + "label.referrer": "Referrer", + "label.referrers": "Hivatkozók", + "label.refresh": "Frissítés", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Hátralévő", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "Kötelező", + "label.reset": "Visszaállítás", + "label.reset-website": "Reset statistics", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Bevétel", + "label.revenue-description": "Tekintse meg bevételeit az idő múlásával.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Mentés", + "label.screens": "Képernyők", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Szűrő kiválasztása", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Munkamenet", + "label.session-data": "Munkamenet adatai", + "label.sessions": "Sessions", + "label.settings": "Beállítások", + "label.share": "Megosztás", + "label.share-url": "URL megosztása", + "label.single-day": "Egy nap", + "label.sms": "SMS", + "label.sources": "Források", + "label.start-step": "Kezdő lépés", + "label.steps": "Lépések", + "label.sum": "Sum", + "label.tablet": "Táblagép", + "label.tag": "Címke", + "label.tags": "Címkék", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Csapat beállításai", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Kifejezések", + "label.theme": "Theme", + "label.this-month": "Ezen hónap", + "label.this-week": "Ezen hét", + "label.this-year": "Ezen év", + "label.timezone": "Időzóna", + "label.title": "Title", + "label.today": "Ma", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Követési kód", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Egyedi látogatók", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Ismeretlen", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Felhasználónév", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Részletek", + "label.view-only": "View only", + "label.views": "Megtekintések", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Átlagos látogatási idő", + "label.visitors": "Látogatók", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Weboldalak", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} {x, plural, one {látogató} other {latógató}} jelenleg", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Biztos, hogy törölni szeretnéd {target} elemet?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "Minden társított adat törlésre kerül.", + "message.error": "Valami baj történt.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Tovább a beállításokhoz", + "message.incorrect-username-password": "Érvénytelen felhasználónév/jelszó.", + "message.invalid-domain": "Érvénytelen domain", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Nincs rendelkezésre álló adat.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "A jelszavak nem egyeznek", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "Még nem állítottál be egyetlen weboldalt sem.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Oldal nem található.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.", + "message.saved": "Sikeres mentés.", + "message.sever-error": "Server error", + "message.share-url": "{target} nyilvánosan megosztott URL címe.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Követési kód", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Látógató {country} területéről, {os} {device} eszközön, {browser} böngészőből." +} diff --git a/src/lang/id-ID.json b/src/lang/id-ID.json new file mode 100644 index 0000000..30a64b6 --- /dev/null +++ b/src/lang/id-ID.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Kode akses", + "label.actions": "Aksi", + "label.activity": "Catatan aktivitas", + "label.add": "Tambah", + "label.add-board": "Tambah papan", + "label.add-description": "Tambah deskripsi", + "label.add-member": "Tambah anggota", + "label.add-step": "Tambah langkah", + "label.add-website": "Tambah situs web", + "label.admin": "Pengelola", + "label.affiliate": "Afiliasi", + "label.after": "Setelah", + "label.all": "Semua", + "label.all-time": "Semua waktu", + "label.analytics": "Analitik", + "label.apply": "Terapkan", + "label.attribution": "Atribusi", + "label.attribution-description": "Lihat bagaimana pengguna berinteraksi dengan pemasaran Anda dan apa yang mendorong konversi.", + "label.average": "Rata-rata", + "label.back": "Kembali", + "label.before": "Sebelum", + "label.behavior": "Perilaku", + "label.boards": "Papan", + "label.bounce-rate": "Rasio pentalan", + "label.breakdown": "Rincian", + "label.browser": "Peramban", + "label.browsers": "Peramban", + "label.campaigns": "Kampanye", + "label.cancel": "Batal", + "label.change-password": "Ganti kata sandi", + "label.channels": "Saluran", + "label.cities": "Kota", + "label.city": "Kota", + "label.clear-all": "Hapus semua", + "label.cohort": "Kelompok", + "label.compare": "Bandingkan", + "label.compare-dates": "Bandingkan tanggal", + "label.confirm": "Konfirmasi", + "label.confirm-password": "Konfirmasi kata sandi", + "label.contains": "Mengandung", + "label.content": "Konten", + "label.continue": "Lanjutkan", + "label.conversion": "Konversi", + "label.conversion-rate": "Tingkat konversi", + "label.conversion-step": "Langkah konversi", + "label.count": "Jumlah", + "label.countries": "Negara", + "label.country": "Negara", + "label.create": "Buat", + "label.create-report": "Buat laporan", + "label.create-team": "Buat tim", + "label.create-user": "Buat pengguna", + "label.created": "Dibuat", + "label.created-by": "Dibuat oleh", + "label.currency": "Mata uang", + "label.current": "Saat ini", + "label.current-password": "Kata sandi sekarang", + "label.custom-range": "Rentang khusus", + "label.dashboard": "Dasbor", + "label.data": "Data", + "label.date": "Tanggal", + "label.date-range": "Rentang tanggal", + "label.day": "Hari", + "label.default-date-range": "Rentang tanggal bawaan", + "label.delete": "Hapus", + "label.delete-report": "Hapus laporan", + "label.delete-team": "Hapus tim", + "label.delete-user": "Hapus pengguna", + "label.delete-website": "Hapus situs web", + "label.description": "Deskripsi", + "label.desktop": "Desktop", + "label.details": "Detail", + "label.device": "Perangkat", + "label.devices": "Perangkat", + "label.direct": "Langsung", + "label.dismiss": "Tutup", + "label.distinct-id": "ID unik", + "label.does-not-contain": "Tidak mengandung", + "label.does-not-include": "Tidak termasuk", + "label.doest-not-exist": "Tidak ada", + "label.domain": "Domain", + "label.dropoff": "Penurunan", + "label.edit": "Sunting", + "label.edit-dashboard": "Sunting dasbor", + "label.edit-member": "Sunting anggota", + "label.email": "Email", + "label.enable-share-url": "Aktifkan URL berbagi", + "label.end-step": "Langkah akhir", + "label.entry": "URL masuk", + "label.event": "Peristiwa", + "label.event-data": "Data peristiwa", + "label.event-name": "Nama peristiwa", + "label.events": "Peristiwa", + "label.exists": "Ada", + "label.exit": "Exit URL", + "label.false": "Salah", + "label.field": "Kolom", + "label.fields": "Kolom", + "label.filter": "Filter", + "label.filter-combined": "Gabungan", + "label.filter-raw": "Mentah", + "label.filters": "Filters", + "label.first-click": "Klik pertama", + "label.first-seen": "Pertama kali dilihat", + "label.funnel": "Funnel", + "label.funnel-description": "Pahami tingkat konversi dan penurunan pengguna.", + "label.funnels": "Corong", + "label.goal": "Tujuan", + "label.goals": "Tujuan", + "label.goals-description": "Lacak tujuan Anda untuk tampilan halaman dan peristiwa.", + "label.greater-than": "Lebih dari", + "label.greater-than-equals": "Lebih dari atau sama dengan", + "label.grouped": "Dikelompokkan", + "label.hostname": "Nama host", + "label.includes": "Termasuk", + "label.insight": "Wawasan", + "label.insights": "Wawasan", + "label.insights-description": "Jelajahi data Anda lebih dalam dengan menggunakan segmen dan filter.", + "label.is": "Adalah", + "label.is-false": "Salah", + "label.is-not": "Bukan", + "label.is-not-set": "Tidak diatur", + "label.is-set": "Diatur", + "label.is-true": "Benar", + "label.join": "Gabung", + "label.join-team": "Gabung tim", + "label.journey": "Perjalanan", + "label.journey-description": "Pahami bagaimana pengguna menavigasi situs web Anda.", + "label.journeys": "Perjalanan", + "label.language": "Bahasa", + "label.languages": "Bahasa", + "label.laptop": "Laptop", + "label.last-click": "Klik terakhir", + "label.last-days": "{x} hari terakhir", + "label.last-hours": "{x} jam terakhir", + "label.last-months": "{x} bulan terakhir", + "label.last-seen": "Terakhir kali dilihat", + "label.leave": "Keluar", + "label.leave-team": "Keluar dari tim", + "label.less-than": "Kurang dari", + "label.less-than-equals": "Kurang dari atau sama dengan", + "label.links": "Tautan", + "label.login": "Masuk", + "label.logout": "Keluar", + "label.manage": "Kelola", + "label.manager": "Manajer", + "label.max": "Maksimum", + "label.maximize": "Perluas", + "label.medium": "Sedang", + "label.member": "Anggota", + "label.members": "Anggota", + "label.min": "Minimum", + "label.mobile": "Ponsel", + "label.model": "Model", + "label.more": "Lebih banyak", + "label.my-account": "Akun saya", + "label.my-websites": "Situs web saya", + "label.name": "Nama", + "label.new-password": "Kata sandi baru", + "label.none": "Tidak ada", + "label.number-of-records": "{x} {x, plural, one {catatan} other {catatan}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Pencarian organik", + "label.organic-shopping": "Belanja organik", + "label.organic-social": "Sosial organik", + "label.organic-video": "Video organik", + "label.os": "OS", + "label.other": "Lainnya", + "label.overview": "Tinjauan umum", + "label.owner": "Pemilik", + "label.page": "Halaman", + "label.page-of": "Halaman {current} dari {total}", + "label.page-views": "Tampilan halaman", + "label.pageTitle": "Judul halaman", + "label.pages": "Halaman", + "label.paid-ads": "Iklan berbayar", + "label.paid-search": "Pencarian berbayar", + "label.paid-shopping": "Belanja berbayar", + "label.paid-social": "Sosial berbayar", + "label.paid-video": "Video berbayar", + "label.password": "Kata sandi", + "label.path": "Jalur", + "label.paths": "Jalur", + "label.pixels": "Piksel", + "label.powered-by": "Didukung oleh {name}", + "label.previous": "Sebelumnya", + "label.previous-period": "Periode sebelumnya", + "label.previous-year": "Tahun lalu", + "label.profile": "Profil", + "label.properties": "Properti", + "label.property": "Properti", + "label.queries": "Kueri", + "label.query": "Kueri", + "label.query-parameters": "Parameter kueri", + "label.realtime": "Waktu nyata", + "label.referral": "Rujukan", + "label.referrer": "Perujuk", + "label.referrers": "Perujuk", + "label.refresh": "Segarkan", + "label.regenerate": "Buat ulang", + "label.region": "Wilayah", + "label.regions": "Wilayah", + "label.remaining": "Tersisa", + "label.remove": "Hapus", + "label.remove-member": "Hapus anggota", + "label.reports": "Laporan", + "label.required": "Wajib", + "label.reset": "Atur ulang", + "label.reset-website": "Atur ulang statistik", + "label.retention": "Retensi", + "label.retention-description": "Ukur daya tarik situs web Anda dengan melacak seberapa sering pengguna kembali.", + "label.revenue": "Pendapatan", + "label.revenue-description": "Lihat pendapatan Anda seiring waktu.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Simpan", + "label.screens": "Layar", + "label.search": "Cari", + "label.select": "Pilih", + "label.select-date": "Pilih tanggal", + "label.select-filter": "Pilih filter", + "label.select-role": "Pilih role", + "label.select-website": "Pilih situs web", + "label.session": "Sesi", + "label.session-data": "Data sesi", + "label.sessions": "Sesi", + "label.settings": "Pengaturan", + "label.share": "Bagikan", + "label.share-url": "Bagikan URL", + "label.single-day": "Sehari", + "label.sms": "SMS", + "label.sources": "Sumber", + "label.start-step": "Langkah awal", + "label.steps": "Langkah", + "label.sum": "Sum", + "label.tablet": "Tablet", + "label.tag": "Tag", + "label.tags": "Tag", + "label.team": "Tim", + "label.team-id": "ID tim", + "label.team-manager": "Pengelola tim", + "label.team-member": "Anggota tim", + "label.team-name": "Nama tim", + "label.team-owner": "Pemilik tim", + "label.team-settings": "Pengaturan tim", + "label.team-view-only": "Team view only", + "label.team-websites": "Situs web tim", + "label.teams": "Tim", + "label.terms": "Ketentuan", + "label.theme": "Tema", + "label.this-month": "Bulan ini", + "label.this-week": "Minggu ini", + "label.this-year": "Tahun ini", + "label.timezone": "Zona waktu", + "label.title": "Judul", + "label.today": "Hari ini", + "label.toggle-charts": "Buka grafik", + "label.total": "Total", + "label.total-records": "Total baris", + "label.tracking-code": "Kode lacak", + "label.transactions": "Transaksi", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer situs web", + "label.true": "Benar", + "label.type": "Tipe", + "label.unique": "Unik", + "label.unique-visitors": "Pengunjung unik", + "label.uniqueCustomers": "Kustomer unik", + "label.unknown": "Tidak diketahui", + "label.untitled": "Tanpa judul", + "label.update": "Perbarui", + "label.user": "Pengguna", + "label.username": "Nama pengguna", + "label.users": "Pengguna", + "label.utm": "UTM", + "label.utm-description": "Lacak kampanye Anda melalui parameter UTM.", + "label.value": "Nilai", + "label.view": "Lihat", + "label.view-details": "Lihat Detil", + "label.view-only": "Hanya melihat", + "label.views": "Tampilan", + "label.views-per-visit": "Tampilan per kunjungan", + "label.visit-duration": "Waktu kunjungan rata-rata", + "label.visitors": "Pengunjung", + "label.visits": "Kunjungan", + "label.website": "Situs web", + "label.website-id": "ID situs web", + "label.websites": "Situs web", + "label.window": "Window", + "label.yesterday": "Kemarin", + "message.action-confirmation": "Ketik {confirmation} pada kotak di bawah untuk mengonfirmasi.", + "message.active-users": "{x} pengunjung saat ini", + "message.bad-request": "Bad request", + "message.collected-data": "Data dikumpulkan", + "message.confirm-delete": "Apakah kamu yakin ingin menghapus {target}?", + "message.confirm-leave": "Apakah Anda yakin ingin meninggalkan {target}?", + "message.confirm-remove": "Apakah Anda yakin ingin menghapus {target}?", + "message.confirm-reset": "Anda yakin ingin mengatur ulang statistik {target}?", + "message.delete-team-warning": "Menghapus tim juga akan menghapus semua situs web yang terkait.", + "message.delete-website-warning": "Semua data terkait juga akan dihapus.", + "message.error": "Ada yang salah.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Pergi ke pengaturan", + "message.incorrect-username-password": "Nama pengguna/kata sandi salah.", + "message.invalid-domain": "Domain tidak valid", + "message.min-password-length": "Minimal {n} karakter", + "message.new-version-available": "Versi baru dari Umami {version} telah tersedia!", + "message.no-data-available": "Tidak ada data.", + "message.no-event-data": "Tidak ada data peristiwa", + "message.no-match-password": "Kata sandi tidak cocok", + "message.no-results-found": "Tidak ada hasil yang ditemukan.", + "message.no-team-websites": "Tim ini tidak memiliki situs web.", + "message.no-teams": "Anda belum membuat tim.", + "message.no-users": "Tidak ada pengguna.", + "message.no-websites-configured": "Anda tidak memiliki situs web yang dikonfigurasi.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Halaman tidak ditemukan.", + "message.reset-website": "Untuk mengatur ulang situs web ini, ketik {confirmation} pada kotak di bawah untuk mengonfirmasi.", + "message.reset-website-warning": "Semua statistik pada situs web ini akan dihapus, tetapi kode lacak akan tetap terpasang", + "message.saved": "Berhasil disimpan.", + "message.sever-error": "Server error", + "message.share-url": "Ini adalah URL yang dibagikan secara publik untuk {target}.", + "message.team-already-member": "Anda sudah menjadi anggota tim ini.", + "message.team-not-found": "Tim tidak ditemukan.", + "message.team-websites-info": "Situs web dapat dilihat oleh semua anggota tim.", + "message.tracking-code": "Kode lacak", + "message.transfer-team-website-to-user": "Transfer situs web ini ke akun Anda?", + "message.transfer-user-website-to-team": "Pilih tim tujuan untuk mentransfer situs web ini.", + "message.transfer-website": "Transfer kepemilikan situs web ke akun Anda atau tim lain", + "message.triggered-event": "Peristiwa terjadi", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Pengguna telah dihapus.", + "message.viewed-page": "Halaman dilihat", + "message.visitor-log": "Pengunjung dari {country} dengan {browser} di {device} {os}" +} diff --git a/src/lang/it-IT.json b/src/lang/it-IT.json new file mode 100644 index 0000000..40cb5ec --- /dev/null +++ b/src/lang/it-IT.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Codice di accesso", + "label.actions": "Azioni", + "label.activity": "Registro attività", + "label.add": "Aggiungi", + "label.add-board": "Aggiungi bacheca", + "label.add-description": "Aggiungi descrizione", + "label.add-member": "Aggiungi membro", + "label.add-step": "Aggiungi passaggio", + "label.add-website": "Aggiungi sito", + "label.admin": "Amministratore", + "label.affiliate": "Affiliato", + "label.after": "Dopo", + "label.all": "Tutto", + "label.all-time": "Sempre", + "label.analytics": "Analitica", + "label.apply": "Applica", + "label.attribution": "Attribuzione", + "label.attribution-description": "Scopri come gli utenti interagiscono con il tuo marketing e cosa genera conversioni.", + "label.average": "Media", + "label.back": "Indietro", + "label.before": "Prima", + "label.behavior": "Comportamento", + "label.boards": "Bacheche", + "label.bounce-rate": "Frequenza di rimbalzo", + "label.breakdown": "Dettaglio", + "label.browser": "Browser", + "label.browsers": "Browser", + "label.campaigns": "Campagne", + "label.cancel": "Annulla", + "label.change-password": "Modifica password", + "label.channels": "Canali", + "label.cities": "Città", + "label.city": "Città", + "label.clear-all": "Cancella tutto", + "label.cohort": "Coorte", + "label.compare": "Confronta", + "label.compare-dates": "Confronta date", + "label.confirm": "Conferma", + "label.confirm-password": "Conferma password", + "label.contains": "Contains", + "label.content": "Contenuto", + "label.continue": "Continua", + "label.conversion": "Conversione", + "label.conversion-rate": "Tasso di conversione", + "label.conversion-step": "Passaggio di conversione", + "label.count": "Conteggio", + "label.countries": "Nazioni", + "label.country": "Paese", + "label.create": "Crea", + "label.create-report": "Crea rapporto", + "label.create-team": "Crea team", + "label.create-user": "Crea utente", + "label.created": "Creato", + "label.created-by": "Creato da", + "label.currency": "Valuta", + "label.current": "Attuale", + "label.current-password": "Password attuale", + "label.custom-range": "Personalizzato", + "label.dashboard": "Pannello di Controllo", + "label.data": "Dati", + "label.date": "Data", + "label.date-range": "Periodo", + "label.day": "Giorno", + "label.default-date-range": "Periodo standard", + "label.delete": "Elimina", + "label.delete-report": "Elimina rapporto", + "label.delete-team": "Elimina team", + "label.delete-user": "Elimina utente", + "label.delete-website": "Elimina sito", + "label.description": "Descrizione", + "label.desktop": "Desktop", + "label.details": "Dettagli", + "label.device": "Dispositivo", + "label.devices": "Dispositivi", + "label.direct": "Diretto", + "label.dismiss": "Scarta", + "label.distinct-id": "ID distinto", + "label.does-not-contain": "Non contiene", + "label.does-not-include": "Non include", + "label.doest-not-exist": "Non esiste", + "label.domain": "Dominio", + "label.dropoff": "Abbandono", + "label.edit": "Modifica", + "label.edit-dashboard": "Modifica pannello di controllo", + "label.edit-member": "Modifica membro", + "label.email": "Email", + "label.enable-share-url": "Abilita URL di condivisione", + "label.end-step": "Passaggio finale", + "label.entry": "URL di ingresso", + "label.event": "Evento", + "label.event-data": "Dati evento", + "label.event-name": "Nome evento", + "label.events": "Eventi", + "label.exists": "Esiste", + "label.exit": "URL di uscita", + "label.false": "Falso", + "label.field": "Campo", + "label.fields": "Campi", + "label.filter": "Filter", + "label.filter-combined": "Aggregati", + "label.filter-raw": "Raw", + "label.filters": "Filtri", + "label.first-click": "Primo clic", + "label.first-seen": "Prima visualizzazione", + "label.funnel": "Funnel", + "label.funnel-description": "Comprendi il tasso di conversione e di abbandono degli utenti.", + "label.funnels": "Funnel", + "label.goal": "Obiettivo", + "label.goals": "Obiettivi", + "label.goals-description": "Tieni traccia dei tuoi obiettivi per visualizzazioni di pagina ed eventi.", + "label.greater-than": "Maggiore di", + "label.greater-than-equals": "Maggiore o uguale a", + "label.grouped": "Raggruppato", + "label.hostname": "Nome host", + "label.includes": "Include", + "label.insight": "Approfondimento", + "label.insights": "Approfondimenti", + "label.insights-description": "Analizza più a fondo i tuoi dati utilizzando segmenti e filtri.", + "label.is": "È", + "label.is-false": "È falso", + "label.is-not": "Non è", + "label.is-not-set": "Non impostato", + "label.is-set": "Impostato", + "label.is-true": "È vero", + "label.join": "Unisciti", + "label.join-team": "Unisciti al team", + "label.journey": "Percorso", + "label.journey-description": "Comprendi come gli utenti navigano nel tuo sito web.", + "label.journeys": "Percorsi", + "label.language": "Lingua", + "label.languages": "Lingue", + "label.laptop": "Portatile", + "label.last-click": "Ultimo clic", + "label.last-days": "Ultimi {x} giorni", + "label.last-hours": "Ultime {x} ore", + "label.last-months": "Ultimi {x} mesi", + "label.last-seen": "Ultima visualizzazione", + "label.leave": "Lascia", + "label.leave-team": "Lascia il team", + "label.less-than": "Meno di", + "label.less-than-equals": "Meno o uguale a", + "label.links": "Link", + "label.login": "Accedi", + "label.logout": "Esci", + "label.manage": "Gestisci", + "label.manager": "Manager", + "label.max": "Massimo", + "label.maximize": "Espandi", + "label.medium": "Medio", + "label.member": "Membro", + "label.members": "Membri", + "label.min": "Minimo", + "label.mobile": "Cellulare", + "label.model": "Model", + "label.more": "Dettagli", + "label.my-account": "Il mio account", + "label.my-websites": "I miei siti", + "label.name": "Nome", + "label.new-password": "Nuova password", + "label.none": "Nessuno", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Ricerca organica", + "label.organic-shopping": "Acquisto organico", + "label.organic-social": "Social organico", + "label.organic-video": "Video organico", + "label.os": "OS", + "label.other": "Altro", + "label.overview": "Overview", + "label.owner": "Proprietario", + "label.page": "Pagina", + "label.page-of": "Page {current} of {total}", + "label.page-views": "Visualizzazioni di pagina", + "label.pageTitle": "Page title", + "label.pages": "Pagine", + "label.paid-ads": "Annunci a pagamento", + "label.paid-search": "Ricerca a pagamento", + "label.paid-shopping": "Acquisto a pagamento", + "label.paid-social": "Social a pagamento", + "label.paid-video": "Video a pagamento", + "label.password": "Password", + "label.path": "Percorso", + "label.paths": "Percorsi", + "label.pixels": "Pixel", + "label.powered-by": "Powered by {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Profilo", + "label.properties": "Proprietà", + "label.property": "Proprietà", + "label.queries": "Query", + "label.query": "Query", + "label.query-parameters": "Parametri query", + "label.realtime": "Tempo reale", + "label.referral": "Referente", + "label.referrer": "Referrer", + "label.referrers": "Referrers", + "label.refresh": "Ricarica", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Rimanente", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "Obbligatorio", + "label.reset": "Reset", + "label.reset-website": "Resetta le statistiche", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Ricavi", + "label.revenue-description": "Consulta i tuoi ricavi nel tempo.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Salva", + "label.screens": "Schermi", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Seleziona filtro", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Session", + "label.session-data": "Dati sessione", + "label.sessions": "Sessions", + "label.settings": "Impostazioni", + "label.share": "Condividi", + "label.share-url": "Condividi link", + "label.single-day": "Singolo giorno", + "label.sms": "SMS", + "label.sources": "Fonti", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "Tablet", + "label.tag": "Etichetta", + "label.tags": "Etichette", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Impostazioni team", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Termini", + "label.theme": "Tema", + "label.this-month": "Questo mese", + "label.this-week": "Questa settimana", + "label.this-year": "Quest'anno", + "label.timezone": "Fuso orario", + "label.title": "Title", + "label.today": "Oggi", + "label.toggle-charts": "Apri/Chiudi i grafici", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Codice di tracking", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Visitatori unici", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Sconosciuto", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Nome utente", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Vedi dettagli", + "label.view-only": "View only", + "label.views": "Visualizzazioni", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Tempo medio di visita", + "label.visitors": "Visitatori", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Siti web", + "label.window": "Window", + "label.yesterday": "Ieri", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} {x, plural, one {visitatore} other {visitatori}} online", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Sei sicuro di voler eliminare {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Sei sicuro di voler azzerare le statistiche di {target}?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "Saranno eliminati anche tutti i dati associati.", + "message.error": "Si è verificato un errore.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Vai alle impostazioni", + "message.incorrect-username-password": "Username o password non corretti.", + "message.invalid-domain": "Dominio non valido", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Nessun dato disponibile.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Le password non corrispondono", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "Non hai ancora configurato alcun sito.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Pagina non trovata", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "Tutte le statistiche verranno cancellate per questo sito, ma il tuo codice di tracciamento rimarrà invariato.", + "message.saved": "Salvato!", + "message.sever-error": "Server error", + "message.share-url": "Questo è l'URL di condivisione per {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Codice di tracking", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Utenti da {country} tramite {browser} su {os} {device}" +} diff --git a/src/lang/ja-JP.json b/src/lang/ja-JP.json new file mode 100644 index 0000000..7d2bf40 --- /dev/null +++ b/src/lang/ja-JP.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "アクセスコード", + "label.actions": "アクション", + "label.activity": "アクティビティログ", + "label.add": "追加", + "label.add-board": "ボードを追加", + "label.add-description": "説明を追加", + "label.add-member": "メンバーの追加", + "label.add-step": "ステップを追加", + "label.add-website": "Webサイトの追加", + "label.admin": "管理者", + "label.affiliate": "アフィリエイト", + "label.after": "直後", + "label.all": "すべて", + "label.all-time": "すべての時間帯", + "label.analytics": "アナリティクス", + "label.apply": "適用", + "label.attribution": "アトリビューション", + "label.attribution-description": "ユーザーがあなたのマーケティングにどのように関与し、何がコンバージョンを促進するかを確認します。", + "label.average": "平均", + "label.back": "戻る", + "label.before": "直前", + "label.behavior": "行動", + "label.boards": "ボード", + "label.bounce-rate": "直帰率", + "label.breakdown": "故障", + "label.browser": "ブラウザ", + "label.browsers": "ブラウザ", + "label.campaigns": "キャンペーン", + "label.cancel": "キャンセル", + "label.change-password": "パスワードの変更", + "label.channels": "チャンネル", + "label.cities": "都市", + "label.city": "都市", + "label.clear-all": "すべてクリア", + "label.cohort": "コホート", + "label.compare": "比較", + "label.compare-dates": "日付を比較", + "label.confirm": "確認", + "label.confirm-password": "パスワード(確認)", + "label.contains": "コンテンツ", + "label.content": "コンテンツ", + "label.continue": "続ける", + "label.conversion": "コンバージョン", + "label.conversion-rate": "コンバージョン率", + "label.conversion-step": "コンバージョンステップ", + "label.count": "回数", + "label.countries": "国名", + "label.country": "国", + "label.create": "作成", + "label.create-report": "レポートの作成", + "label.create-team": "チームの作成", + "label.create-user": "ユーザーの作成", + "label.created": "作成されました", + "label.created-by": "作成者", + "label.currency": "通貨", + "label.current": "現在", + "label.current-password": "現在のパスワード", + "label.custom-range": "範囲指定", + "label.dashboard": "ダッシュボード", + "label.data": "データ", + "label.date": "日付", + "label.date-range": "期間", + "label.day": "日", + "label.default-date-range": "デフォルトの期間", + "label.delete": "削除", + "label.delete-report": "レポートの削除", + "label.delete-team": "チームの削除", + "label.delete-user": "ユーザーの削除", + "label.delete-website": "Webサイトの削除", + "label.description": "説明", + "label.desktop": "デスクトップ", + "label.details": "詳細情報", + "label.device": "デバイス", + "label.devices": "デバイス", + "label.direct": "ダイレクト", + "label.dismiss": "却下", + "label.distinct-id": "識別ID", + "label.does-not-contain": "を含まない", + "label.does-not-include": "含まない", + "label.doest-not-exist": "存在しない", + "label.domain": "ドメイン", + "label.dropoff": "切り捨て", + "label.edit": "編集", + "label.edit-dashboard": "ダッシュボードの編集", + "label.edit-member": "メンバーの編集", + "label.email": "メール", + "label.enable-share-url": "共有URLを有効にする", + "label.end-step": "最終ステップ", + "label.entry": "訪問時のURL", + "label.event": "イベント", + "label.event-data": "イベントデータ", + "label.event-name": "イベント名", + "label.events": "イベント", + "label.exists": "存在する", + "label.exit": "退出時のURL", + "label.false": "偽", + "label.field": "フィールド", + "label.fields": "フィールド", + "label.filter": "フィルター", + "label.filter-combined": "結合", + "label.filter-raw": "RAW", + "label.filters": "フィルター", + "label.first-click": "最初のクリック", + "label.first-seen": "初回ログイン", + "label.funnel": "ファネル", + "label.funnel-description": "ユーザーのコンバージョン率と離脱率を分析します。", + "label.funnels": "ファネル", + "label.goal": "目標", + "label.goals": "目標", + "label.goals-description": "ページビューとイベントの目標を追跡します。", + "label.greater-than": "超過", + "label.greater-than-equals": "以上", + "label.grouped": "グループ化", + "label.hostname": "ホスト名", + "label.includes": "含む", + "label.insight": "インサイト", + "label.insights": "インサイト", + "label.insights-description": "セグメントとフィルタを使用して、データをさらに詳しく分析します。", + "label.is": "に等しい", + "label.is-false": "偽である", + "label.is-not": "に等しくない", + "label.is-not-set": "未設定", + "label.is-set": "設定済み", + "label.is-true": "真である", + "label.join": "参加", + "label.join-team": "チームに参加", + "label.journey": "ジャーニー", + "label.journey-description": "ユーザーがWebサイト内をどのように移動するかを把握します。", + "label.journeys": "ジャーニー", + "label.language": "言語", + "label.languages": "言語", + "label.laptop": "ノートPC", + "label.last-click": "最後のクリック", + "label.last-days": "過去{x}日間", + "label.last-hours": "過去{x}時間", + "label.last-months": "過去{x}月間", + "label.last-seen": "最終ログイン", + "label.leave": "離脱", + "label.leave-team": "チームを離脱", + "label.less-than": "未満", + "label.less-than-equals": "以下", + "label.links": "リンク", + "label.login": "ログイン", + "label.logout": "ログアウト", + "label.manage": "管理", + "label.manager": "管理者", + "label.max": "最大", + "label.maximize": "展開", + "label.medium": "メディア", + "label.member": "メンバー", + "label.members": "メンバー", + "label.min": "最小", + "label.mobile": "携帯電話", + "label.model": "モデル", + "label.more": "もっと見る", + "label.my-account": "マイアカウント", + "label.my-websites": "マイWebサイト", + "label.name": "名前", + "label.new-password": "新しいパスワード", + "label.none": "なし", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "オーガニック検索", + "label.organic-shopping": "オーガニックショッピング", + "label.organic-social": "オーガニックソーシャル", + "label.organic-video": "オーガニックビデオ", + "label.os": "OS", + "label.other": "その他", + "label.overview": "概要", + "label.owner": "所有者", + "label.page": "ページ", + "label.page-of": "ページ {current}/{total}", + "label.page-views": "閲覧数", + "label.pageTitle": "ページタイトル", + "label.pages": "ページ", + "label.paid-ads": "有料広告", + "label.paid-search": "有料検索", + "label.paid-shopping": "有料ショッピング", + "label.paid-social": "有料ソーシャル", + "label.paid-video": "有料ビデオ", + "label.password": "パスワード", + "label.path": "パス", + "label.paths": "パス", + "label.pixels": "ピクセル", + "label.powered-by": "Powered by {name}", + "label.previous": "以前", + "label.previous-period": "前期", + "label.previous-year": "前年", + "label.profile": "プロフィール", + "label.properties": "プロパティ", + "label.property": "プロパティ", + "label.queries": "クエリ", + "label.query": "クエリ", + "label.query-parameters": "クエリパラメーター", + "label.realtime": "リアルタイム", + "label.referral": "Referral", + "label.referrer": "リファラー", + "label.referrers": "リファラー", + "label.refresh": "更新", + "label.regenerate": "再生成", + "label.region": "地域", + "label.regions": "地域", + "label.remaining": "残り", + "label.remove": "削除", + "label.remove-member": "メンバーの削除", + "label.reports": "レポート", + "label.required": "必須", + "label.reset": "リセット", + "label.reset-website": "Webサイトをリセットする", + "label.retention": "リテンション", + "label.retention-description": "ユーザーの再訪問回数を記録して、Webサイトのリテンション率を計測します。", + "label.revenue": "レベニュー", + "label.revenue-description": "時間あたりの売上高を確認します。", + "label.role": "ロール", + "label.run-query": "クエリ実行", + "label.save": "保存", + "label.screens": "画面サイズ", + "label.search": "検索", + "label.select": "選択", + "label.select-date": "日付を選択", + "label.select-filter": "フィルターを選択", + "label.select-role": "ロールを選択", + "label.select-website": "Webサイトを選択", + "label.session": "セッション", + "label.session-data": "セッションデータ", + "label.sessions": "セッション", + "label.settings": "設定", + "label.share": "共有", + "label.share-url": "共有URL", + "label.single-day": "一日", + "label.sms": "SMS", + "label.sources": "ソース", + "label.start-step": "最初のステップ", + "label.steps": "ステップ", + "label.sum": "合計", + "label.tablet": "タブレット", + "label.tag": "タグ", + "label.tags": "タグ", + "label.team": "チーム", + "label.team-id": "チームID", + "label.team-manager": "チーム管理者", + "label.team-member": "チームメンバー", + "label.team-name": "チーム名", + "label.team-owner": "チームオーナー", + "label.team-settings": "チーム設定", + "label.team-view-only": "チーム表示のみ", + "label.team-websites": "チームのWebサイト", + "label.teams": "チーム", + "label.terms": "利用規約", + "label.theme": "テーマ", + "label.this-month": "今月", + "label.this-week": "今週", + "label.this-year": "今年", + "label.timezone": "タイムゾーン", + "label.title": "タイトル", + "label.today": "今日", + "label.toggle-charts": "グラフを切り替える", + "label.total": "累計", + "label.total-records": "総記録数", + "label.tracking-code": "トラッキングコード", + "label.transactions": "トランザクション", + "label.transfer": "移管", + "label.transfer-website": "Webサイトの移管", + "label.true": "真", + "label.type": "種別", + "label.unique": "ユニーク", + "label.unique-visitors": "ユニーク訪問者数", + "label.uniqueCustomers": "ユニーク顧客数", + "label.unknown": "不明", + "label.untitled": "無題", + "label.update": "更新", + "label.user": "ユーザー", + "label.username": "ユーザー名", + "label.users": "ユーザー", + "label.utm": "UTM", + "label.utm-description": "UTMパラメーターを使用してキャンペーンを追跡します。", + "label.value": "値", + "label.view": "表示", + "label.view-details": "詳細を表示", + "label.view-only": "表示のみ", + "label.views": "表示", + "label.views-per-visit": "訪問あたりの閲覧数", + "label.visit-duration": "平均滞在時間", + "label.visitors": "訪問者", + "label.visits": "訪問数", + "label.website": "Webサイト", + "label.website-id": "WebサイトID", + "label.websites": "Webサイト", + "label.window": "ウィンドウ", + "label.yesterday": "昨日", + "message.action-confirmation": "承認する場合は、下のフォームに「{confirmation}」と入力してください。", + "message.active-users": "{x} {x, plural, one {アクティブな訪問者} other {アクティブな訪問者}}", + "message.bad-request": "Bad request", + "message.collected-data": "収集されたデータ", + "message.confirm-delete": "{target}を削除してもよろしいですか?", + "message.confirm-leave": "{target}から離脱してもよろしいですか?", + "message.confirm-remove": "{target}を削除してもよろしいですか?", + "message.confirm-reset": "{target}をリセットしてもよろしいですか?", + "message.delete-team-warning": "チームを削除すると、そのチームが管理しているWebサイトもすべて削除されます。", + "message.delete-website-warning": "Webサイトのデータがすべて削除されます。", + "message.error": "未知のエラーが発生しました。", + "message.event-log": "{url}の{event}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "設定に移動する", + "message.incorrect-username-password": "ユーザー名またはパスワードが間違っています。", + "message.invalid-domain": "無効なドメインです。http/httpsを含めないでください。", + "message.min-password-length": "最小文字数は{n}文字です", + "message.new-version-available": "Umamiの新しいバージョン{version}が利用可能です!", + "message.no-data-available": "データがありません。", + "message.no-event-data": "イベントデータがありません。", + "message.no-match-password": "パスワードが一致しません。", + "message.no-results-found": "結果が見つかりません。", + "message.no-team-websites": "このチームにはWebサイトがありません。", + "message.no-teams": "チームを作成していません。", + "message.no-users": "ユーザーが存在しません。", + "message.no-websites-configured": "Webサイトが設定されていません。", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "ページが見つかりません", + "message.reset-website": "このWebサイトをリセットするには、下のフォームに「{confirmation}」と入力してください。", + "message.reset-website-warning": "このWebサイトの統計情報はすべて削除されますが、設定はそのまま残ります。", + "message.saved": "保存されました。", + "message.sever-error": "Server error", + "message.share-url": "あなたのWebサイトの統計情報は次のURLで公開されています:", + "message.team-already-member": "あなたはすでにチームのメンバーです。", + "message.team-not-found": "チームが見つかりません。", + "message.team-websites-info": "Webサイトはチーム内の誰でも見ることができます。", + "message.tracking-code": "このWebサイトの統計情報を追跡するには、HTMLの<head>...</head>セクションに以下のコードを記述します。", + "message.transfer-team-website-to-user": "このWebサイトをあなたのアカウントに移管しますか?", + "message.transfer-user-website-to-team": "このWebサイトを移管するチームを選択してください。", + "message.transfer-website": "Webサイトの所有権を自分のアカウントまたは別のチームへ移管します。", + "message.triggered-event": "トリガーされたイベント", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "ユーザーが削除されました。", + "message.viewed-page": "閲覧されたページ", + "message.visitor-log": "{os}({device})で{browser}を使用している{country}からの訪問者" +} diff --git a/src/lang/km-KH.json b/src/lang/km-KH.json new file mode 100644 index 0000000..087e24d --- /dev/null +++ b/src/lang/km-KH.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "កូដចូលប្រើ", + "label.actions": "សកម្មភាព", + "label.activity": "កំណត់ហេតុសកម្មភាព", + "label.add": "បង្កើតបន្ថែម", + "label.add-board": "បន្ថែមក្តារ", + "label.add-description": "បន្ថែមពិពណ៌នា", + "label.add-member": "បន្ថែមសមាជិក", + "label.add-step": "បន្ថែមជំហាន", + "label.add-website": "បន្ថែមគេហទំព័រ", + "label.admin": "អ្នកគ្រប់គ្រង", + "label.affiliate": "ដៃគូ", + "label.after": "បន្ទាប់", + "label.all": "ទាំងអស់", + "label.all-time": "គ្រប់ពេល", + "label.analytics": "វិភាគ", + "label.apply": "អនុវត្ត", + "label.attribution": "ការបញ្ជាក់", + "label.attribution-description": "មើលថាប្រើប្រាស់របស់អ្នកធ្វើអ្វីជាមួយទីផ្សាររបស់អ្នក និងអ្វីជាហេតុបណ្តាលឲ្យមានការបម្លែង។", + "label.average": "ជាមធ្យម", + "label.back": "ថយក្រោយ", + "label.before": "មុន", + "label.behavior": "អាកប្បកិរិយា", + "label.boards": "ក្តារ", + "label.bounce-rate": "ចំនួនវិលត្រឡប់", + "label.breakdown": "បំបែកលម្អិត", + "label.browser": "កម្មវិធីរុករក", + "label.browsers": "កម្មវិធី", + "label.campaigns": "យុទ្ធនាការ", + "label.cancel": "បោះបង់", + "label.change-password": "ផ្លាស់ប្តូរពាក្យសម្ងាត់", + "label.channels": "ឆានែល", + "label.cities": "ទីក្រុង", + "label.city": "ទីក្រុង", + "label.clear-all": "លុបចេញទាំងអស់", + "label.cohort": "ក្រុម", + "label.compare": "ប្រៀបធៀប", + "label.compare-dates": "ប្រៀបធៀបទិន្នន័យថ្ងៃខែ", + "label.confirm": "បញ្ជាក់", + "label.confirm-password": "បញ្ជាក់ពាក្យសម្ងាត់", + "label.contains": "មាន", + "label.content": "មាតិកា", + "label.continue": "បន្ត", + "label.conversion": "ការបម្លែង", + "label.conversion-rate": "អត្រាបម្លែង", + "label.conversion-step": "ជំហានបម្លែង", + "label.count": "ចំនួន", + "label.countries": "ប្រទេស", + "label.country": "ប្រទេស", + "label.create": "បង្កើត", + "label.create-report": "បង្កើតរបាយការណ៍", + "label.create-team": "បង្កើតក្រុម", + "label.create-user": "បង្កើតអ្នកប្រើប្រាស់", + "label.created": "បង្កើតនៅ", + "label.created-by": "បង្កើតដោយ", + "label.currency": "រូបិយប័ណ្ណ", + "label.current": "បច្ចុប្បន្ន", + "label.current-password": "ពាក្យសម្ងាត់បច្ចុប្បន្ន", + "label.custom-range": "កំណត់ដោយខ្លួនឯង", + "label.dashboard": "ផ្ទាំងគ្រប់គ្រង", + "label.data": "ទិន្នន័យ", + "label.date": "កាលបរិច្ឆេទ", + "label.date-range": "ចន្លោះកាលបរិច្ឆេទ", + "label.day": "ថ្ងៃ", + "label.default-date-range": "ចន្លោះកាលបរិច្ឆេទដើម", + "label.delete": "លុប", + "label.delete-report": "លុបរបាយការណ៍", + "label.delete-team": "លុបក្រុម", + "label.delete-user": "លុបអ្នកប្រើប្រាស់", + "label.delete-website": "លុបគេហទំព័រ", + "label.description": "ការពិពណ៌នា", + "label.desktop": "កុំព្យូទ័រលើតុ", + "label.details": "ព័ត៌មានលម្អិត", + "label.device": "ឧបករណ៍", + "label.devices": "ឧបករណ៍", + "label.direct": "ផ្ទាល់", + "label.dismiss": "រំសាយ", + "label.distinct-id": "លេខសម្គាល់ពិសេស", + "label.does-not-contain": "មិនមាន", + "label.does-not-include": "មិនរួមបញ្ចូល", + "label.doest-not-exist": "មិនមានទេ", + "label.domain": "Domain", + "label.dropoff": "Dropoff", + "label.edit": "កែប្រែ", + "label.edit-dashboard": "កែផ្ទាំងគ្រប់គ្រង", + "label.edit-member": "កែព័ត៌មានសមាជិក", + "label.email": "Email", + "label.enable-share-url": "បើកការចែករំលែក URL", + "label.end-step": "បញ្ចប់ជំហាន", + "label.entry": "URL ចូល", + "label.event": "ព្រឹត្តិការណ៍", + "label.event-data": "ទិន្នន័យព្រឹត្តិការណ៍", + "label.event-name": "ឈ្មោះព្រឹត្តិការណ៍", + "label.events": "ព្រឹត្តិការណ៍", + "label.exists": "មាន", + "label.exit": "URL ចេញ", + "label.false": "មិនពិត", + "label.field": "Field", + "label.fields": "Fields", + "label.filter": "ចម្រោះ", + "label.filter-combined": "រួមបញ្ចូលគ្នា", + "label.filter-raw": "ដើម", + "label.filters": "ចម្រោះ", + "label.first-click": "ចុចដំបូង", + "label.first-seen": "First seen", + "label.funnel": "ផ្លូវបង្ហាញ", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "ផ្លូវបង្ហាញ", + "label.goal": "គោលដៅ", + "label.goals": "គោលដៅ", + "label.goals-description": "តាមដានគោលដៅរបស់អ្នកសម្រាប់ pageviews និង events។", + "label.greater-than": "ធំជាង", + "label.greater-than-equals": "ធំជាងឬស្មើ", + "label.grouped": "បានដាក់ជាក្រុម", + "label.hostname": "ឈ្មោះម៉ាស៊ីន", + "label.includes": "រួមបញ្ចូល", + "label.insight": "ការយល់ដឹង", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "គឺ", + "label.is-false": "មិនពិត", + "label.is-not": "មិនមែន", + "label.is-not-set": "មិនបានកំណត់", + "label.is-set": "បានកំណត់", + "label.is-true": "ពិត", + "label.join": "ចូលរួម", + "label.join-team": "ចូលក្រុម", + "label.journey": "ដំណើរ", + "label.journey-description": "ស្វែងយល់ពីការប្រើប្រាស់គេហទំព័ររបស់អតិថិជនអ្នក។", + "label.journeys": "ដំណើរ", + "label.language": "ភាសា", + "label.languages": "ភាសា", + "label.laptop": "កុំព្យូទ័រយួរដៃ", + "label.last-click": "ចុចចុងក្រោយ", + "label.last-days": "{x} ថ្ងៃចុងក្រោយ", + "label.last-hours": "{x} ម៉ោងចុងក្រោយ", + "label.last-months": "{x} ខែចុងក្រោយ", + "label.last-seen": "Last seen", + "label.leave": "ចាកចេញ", + "label.leave-team": "ចេញពីក្រុម", + "label.less-than": "តិចជាង", + "label.less-than-equals": "តិចជាង ឬស្មើ", + "label.links": "តំណភ្ជាប់", + "label.login": "Login", + "label.logout": "Logout", + "label.manage": "គ្រប់គ្រង", + "label.manager": "អ្នកគ្រប់គ្រង", + "label.max": "Max", + "label.maximize": "ពង្រីក", + "label.medium": "មធ្យម", + "label.member": "សមាជិក", + "label.members": "សមាជិក", + "label.min": "Min", + "label.mobile": "ទូរស័ព្ទចល័ត", + "label.model": "ម៉ូដែល", + "label.more": "បន្ថែម", + "label.my-account": "គណនីរបស់ខ្ញុំ", + "label.my-websites": "គេហទំព័ររបស់ខ្ញុំ", + "label.name": "ឈ្មោះ", + "label.new-password": "ពាក្យសម្ងាត់ថ្មី", + "label.none": "គ្មាន", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "ស្វែងរកធម្មជាតិ", + "label.organic-shopping": "ការទិញធម្មជាតិ", + "label.organic-social": "សង្គមធម្មជាតិ", + "label.organic-video": "វីដេអូធម្មជាតិ", + "label.os": "OS", + "label.other": "ផ្សេងទៀត", + "label.overview": "ទិដ្ឋភាពរួម", + "label.owner": "ម្ចាស់", + "label.page": "ទំព័រ", + "label.page-of": "ទំព័រទី {current} នៃ {total}", + "label.page-views": "អ្នកមើលទំព័រ", + "label.pageTitle": "ចំណងជើងទំព័រ", + "label.pages": "ទំព័រ", + "label.paid-ads": "ផ្សាយពាណិជ្ជកម្មបង់ប្រាក់", + "label.paid-search": "ស្វែងរកបង់ប្រាក់", + "label.paid-shopping": "ទិញបង់ប្រាក់", + "label.paid-social": "សង្គមបង់ប្រាក់", + "label.paid-video": "វីដេអូបង់ប្រាក់", + "label.password": "ពាក្យសម្ងាត់", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "ភីកសែល", + "label.powered-by": "ដំណើរការដោយ {name}", + "label.previous": "មុន", + "label.previous-period": "មួយរយៈពេលមុន", + "label.previous-year": "ឆ្នាំមុន", + "label.profile": "គណនី", + "label.properties": "លក្ខណៈពិសេស", + "label.property": "លក្ខណៈពិសេស", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "ប៉ារ៉ាម៉ែត្រ Query", + "label.realtime": "ឥលូវនេះ", + "label.referral": "ការបញ្ជូន", + "label.referrer": "អ្នកណែនាំ", + "label.referrers": "អ្នកណែនាំ", + "label.refresh": "ផ្ទុកឡើងវិញ", + "label.regenerate": "Regenerate", + "label.region": "តំបន់", + "label.regions": "តំបន់", + "label.remaining": "នៅសល់", + "label.remove": "លុប", + "label.remove-member": "លុបសមាជិកក្រុម", + "label.reports": "របាយការណ៍", + "label.required": "ទាមទារ", + "label.reset": "កែសម្រួល", + "label.reset-website": "ដើម្បីកែគេហទំព័រនេះឡើងវិញ សូមសរសេរ {confirmation} នៅក្នុងប្រអប់ខាងក្រោមដើម្បីបញ្ជាក់។", + "label.retention": "ការរក្សាទុក", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "មុខងារ", + "label.run-query": "Run query", + "label.save": "រក្សាទុក", + "label.screens": "ប្រភេទឧបករណ៍", + "label.search": "ស្វែងរក", + "label.select": "ជ្រើសរើស", + "label.select-date": "ជ្រើសរើសកាលបរិច្ឆេទ", + "label.select-filter": "ជ្រើសរើសតម្រង", + "label.select-role": "ជ្រើសរើសមុខងារ", + "label.select-website": "ជ្រើសរើសគេហទំព័រ", + "label.session": "Session", + "label.session-data": "ទិន្នន័យសម័យ", + "label.sessions": "Sessions", + "label.settings": "ការកំណត់", + "label.share": "ចែករំលែក", + "label.share-url": "ចែករំលែក URL", + "label.single-day": "ថ្ងៃតែមួយ", + "label.sms": "SMS", + "label.sources": "ប្រភព", + "label.start-step": "ជំហានចាប់ផ្តើម", + "label.steps": "ជំហាន", + "label.sum": "Sum", + "label.tablet": "ថេប្លេត", + "label.tag": "ស្លាក", + "label.tags": "ស្លាក", + "label.team": "ក្រុម", + "label.team-id": "ID ក្រុម", + "label.team-manager": "អ្នកគ្រប់គ្រងក្រុម", + "label.team-member": "សមាជិកក្រុម", + "label.team-name": "ឈ្មោះក្រុម", + "label.team-owner": "ម្ចាស់ក្រុម", + "label.team-settings": "ការកំណត់ក្រុម", + "label.team-view-only": "Team view only", + "label.team-websites": "គេហទំព័ររបស់ក្រុម", + "label.teams": "ក្រុម", + "label.terms": "លក្ខខណ្ឌ", + "label.theme": "រូបរាង", + "label.this-month": "ខែនេះ", + "label.this-week": "សប្តាហ៍នេះ", + "label.this-year": "ឆ្នាំនេះ", + "label.timezone": "តំបន់ម៉ោង", + "label.title": "ចំណងជើង", + "label.today": "ថ្ងៃនេះ", + "label.toggle-charts": "បិទ/បើកតារាង", + "label.total": "សរុប", + "label.total-records": "កំណត់ត្រាសរុប", + "label.tracking-code": "លេខកូដតាមដាន", + "label.transactions": "Transactions", + "label.transfer": "ការផ្ទេរ", + "label.transfer-website": "ការផ្ទេរគេហទំព័រ", + "label.true": "ពិត", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "អ្នកចូលមើលម្នាក់ៗ", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "មិនស្គាល់", + "label.untitled": "គ្មានចំណងជើង", + "label.update": "Update", + "label.user": "អ្នកប្រើប្រាស់", + "label.username": "ឈ្មោះអ្នកប្រើប្រាស់", + "label.users": "អ្នកប្រើប្រាស់", + "label.utm": "UTM", + "label.utm-description": "តាមដានយុទ្ធនាការរបស់អ្នកតាមរយៈប៉ារ៉ាម៉ែត្រ UTM។", + "label.value": "Value", + "label.view": "View", + "label.view-details": "មើលព័ត៌មានលម្អិត", + "label.view-only": "បានតែមើលប៉ុណ្ណោះ", + "label.views": "អ្នកចូលមើល", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "រយៈពេលទស្សនា", + "label.visitors": "អ្នកទស្សនា", + "label.visits": "ទស្សនា", + "label.website": "គេហទំព័រ", + "label.website-id": "ID គេហទំព័រ", + "label.websites": "គេហទំព័រ", + "label.window": "Window", + "label.yesterday": "ម្សិលមិញ", + "message.action-confirmation": "សសេរ {confirmation} នៅក្នុងប្រអប់ខាងក្រោមដើម្បីបញ្ជាក់។", + "message.active-users": "មានអ្នកមើល {x} នាក់ ឥលូវនេះ", + "message.bad-request": "Bad request", + "message.collected-data": "ទិន្នន័យដែលបានប្រមូលទុក", + "message.confirm-delete": "តើអ្នកប្រាកដថាចង់លុប {target} ទេ?", + "message.confirm-leave": "តើអ្នកប្រាកដថាចង់ចាកចេញ {target} ទេ?", + "message.confirm-remove": "តើអ្នកប្រាកដថាចង់លុប {target} ទេ?", + "message.confirm-reset": "តើអ្នកប្រាកដថាចង់កំណត់ស្ថិតិរបស់ {target} ឡើងវិញទេ?", + "message.delete-team-warning": "ពេលលុបក្រុម គេហទំព័ររបស់ក្រុមក៏នឹងត្រូវលប់ចោលទាំងអស់ផងដែរ។", + "message.delete-website-warning": "ទិន្នន័យរបស់គេហទំព័រទាំងអស់នឹងត្រូវលុបចោល។", + "message.error": "មានអ្វីមួយមិនប្រក្រតី។", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "ការកំណត់", + "message.incorrect-username-password": "ឈ្មោះអ្នកប្រើឬពាក្យសម្ងាត់មិនត្រឹមត្រូវ។", + "message.invalid-domain": "Domain មិនត្រឹមត្រូវ", + "message.min-password-length": "តិចបំផុតដែលមានអក្សរ {n} តួអក្សរ", + "message.new-version-available": "Version ថ្មីនៃ Umami {version} អាចប្រើប្រាស់បានហើយ!", + "message.no-data-available": "មិនមានទិន្នន័យ។", + "message.no-event-data": "មិនមានទិន្នន័យព្រឹត្តិការណ៍ទេ។", + "message.no-match-password": "ពាក្យសម្ងាត់មិនត្រូវគ្នាទេ។", + "message.no-results-found": "មិនមានលទ្ធផល។", + "message.no-team-websites": "ក្រុមនេះមិនមានគេហទំព័រទេ។", + "message.no-teams": "អ្នកមិនទាន់បានបង្កើតក្រុមណាមួយទេ។", + "message.no-users": "មិនមានអ្នកប្រើប្រាស់ទេ។", + "message.no-websites-configured": "អ្នកមិនទាន់បានដាក់គេហទំព័រណាមួយចូលទេ។", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "រកមិនឃើញទំព័រ។", + "message.reset-website": "ដើម្បីកែគេហទំព័រនេះឡើងវិញ សូមសរសេរ {confirmation} នៅក្នុងប្រអប់ខាងក្រោមដើម្បីបញ្ជាក់។", + "message.reset-website-warning": "ស្ថិតិទាំងអស់សម្រាប់គេហទំព័រនេះនឹងត្រូវបានលុប ប៉ុន្តែកូដតាមដានរបស់អ្នកនឹងនៅដដែល។", + "message.saved": "រក្សាទុកដោយជោគជ័យ។", + "message.sever-error": "Server error", + "message.share-url": "នេះគឺជា URL ដែលអាចចែករំលែកជាសាធារណៈបានសម្រាប់ {target}។", + "message.team-already-member": "អ្នកគឺជាសមាជិកនៃក្រុមរួចហើយ។", + "message.team-not-found": "រកក្រុមមិនឃើញទេ។", + "message.team-websites-info": "គេហទំព័រនេះអាចមើលបានតែសមាជិកក្រុមតែប៉ុណ្ណោះ", + "message.tracking-code": "ដើម្បីតាមដានស្ថិតិសម្រាប់គេហទំព័រអ្នក សូមដាក់កូដខាងក្រោមទៅក្នុងផ្នែក <head>...</head> នៃ HTML របស់អ្នក។", + "message.transfer-team-website-to-user": "ផ្ទេរគេហទំព័រនេះទៅគណនីរបស់អ្នក។?", + "message.transfer-user-website-to-team": "ជ្រើសក្រុមដែរត្រូវផ្ទេរគេហទំព័រនេះទៅ។", + "message.transfer-website": "ផ្ទេរកម្មសិទ្ធិគេហទំព័រទៅគណនីរបស់អ្នក ឬក្រុមផ្សេងទៀត។", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "អ្នកប្រើប្រាស់ត្រូវបានលុបចោល។", + "message.viewed-page": "ទំព័រដែលបានមើល", + "message.visitor-log": "អ្នកមើលពីប្រទេស {country} ប្រើប្រាស់កម្មវិធី {browser} លើឧបករណ៍ {os} {device}" +} diff --git a/src/lang/ko-KR.json b/src/lang/ko-KR.json new file mode 100644 index 0000000..977eea4 --- /dev/null +++ b/src/lang/ko-KR.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "액세스 코드", + "label.actions": "동작", + "label.activity": "활동", + "label.add": "추가", + "label.add-board": "보드 추가", + "label.add-description": "설명 추가", + "label.add-member": "멤버 추가", + "label.add-step": "단계 추가", + "label.add-website": "웹사이트 추가", + "label.admin": "관리자", + "label.affiliate": "제휴사", + "label.after": "이후", + "label.all": "전체", + "label.all-time": "전체 시간", + "label.analytics": "분석", + "label.apply": "적용", + "label.attribution": "기여도", + "label.attribution-description": "사용자가 마케팅에 어떻게 반응하고 전환을 유도하는지 확인하세요.", + "label.average": "평균", + "label.back": "뒤로", + "label.before": "이전", + "label.behavior": "행동", + "label.boards": "보드", + "label.bounce-rate": "이탈률", + "label.breakdown": "세부 사항", + "label.browser": "브라우저", + "label.browsers": "브라우저", + "label.campaigns": "캠페인", + "label.cancel": "취소", + "label.change-password": "비밀번호 변경", + "label.channels": "채널", + "label.cities": "도시", + "label.city": "도시", + "label.clear-all": "모두 지우기", + "label.cohort": "코호트", + "label.compare": "비교", + "label.compare-dates": "날짜 비교", + "label.confirm": "확인", + "label.confirm-password": "비밀번호 확인", + "label.contains": "포함", + "label.content": "콘텐츠", + "label.continue": "계속", + "label.conversion": "전환", + "label.conversion-rate": "전환율", + "label.conversion-step": "전환 단계", + "label.count": "수", + "label.countries": "국가", + "label.country": "국가", + "label.create": "만들기", + "label.create-report": "보고서 만들기", + "label.create-team": "팀 만들기", + "label.create-user": "사용자 만들기", + "label.created": "생성됨", + "label.created-by": "작성자", + "label.currency": "통화", + "label.current": "현재", + "label.current-password": "현재 비밀번호", + "label.custom-range": "범위 지정", + "label.dashboard": "대시보드", + "label.data": "데이터", + "label.date": "날짜", + "label.date-range": "날짜 범위", + "label.day": "일", + "label.default-date-range": "기본 날짜 범위", + "label.delete": "삭제", + "label.delete-report": "보고서 삭제", + "label.delete-team": "팀 삭제", + "label.delete-user": "사용자 삭제", + "label.delete-website": "웹사이트 삭제", + "label.description": "설명", + "label.desktop": "데스크톱", + "label.details": "세부 정보", + "label.device": "기기", + "label.devices": "기기", + "label.direct": "직접", + "label.dismiss": "무시하기", + "label.distinct-id": "고유 ID", + "label.does-not-contain": "포함하지 않음", + "label.does-not-include": "포함하지 않음", + "label.doest-not-exist": "존재하지 않음", + "label.domain": "도메인", + "label.dropoff": "이탈", + "label.edit": "편집", + "label.edit-dashboard": "대시보드 편집", + "label.edit-member": "멤버 편집", + "label.email": "이메일", + "label.enable-share-url": "URL 공유 활성화", + "label.end-step": "마지막 단계", + "label.entry": "입장 URL", + "label.event": "이벤트", + "label.event-data": "이벤트 데이터", + "label.event-name": "이벤트 이름", + "label.events": "이벤트", + "label.exists": "존재함", + "label.exit": "퇴장 URL", + "label.false": "거짓", + "label.field": "필드", + "label.fields": "필드", + "label.filter": "필터", + "label.filter-combined": "합쳐 보기", + "label.filter-raw": "전체 보기", + "label.filters": "필터", + "label.first-click": "첫 클릭", + "label.first-seen": "첫 접속", + "label.funnel": "퍼널", + "label.funnel-description": "사용자 전환율 및 이탈률을 살펴보세요.", + "label.funnels": "퍼널", + "label.goal": "목표", + "label.goals": "목표", + "label.goals-description": "페이지 조회 및 이벤트 목표를 추적합니다.", + "label.greater-than": "이상", + "label.greater-than-equals": "이상", + "label.grouped": "그룹화됨", + "label.hostname": "호스트명", + "label.includes": "포함", + "label.insight": "인사이트", + "label.insights": "인사이트", + "label.insights-description": "세그먼트 및 필터를 사용하여 데이터를 더 자세히 살펴보세요.", + "label.is": "해당", + "label.is-false": "거짓임", + "label.is-not": "해당하지 않음", + "label.is-not-set": "설정되지 않음", + "label.is-set": "설정됨", + "label.is-true": "참임", + "label.join": "가입하기", + "label.join-team": "팀 가입하기", + "label.journey": "여정", + "label.journey-description": "사용자가 웹사이트를 탐색하는 경로를 살펴보세요.", + "label.journeys": "여정", + "label.language": "언어", + "label.languages": "언어", + "label.laptop": "노트북", + "label.last-click": "마지막 클릭", + "label.last-days": "지난 {x}일", + "label.last-hours": "지난 {x}시간", + "label.last-months": "지난 {x}개월", + "label.last-seen": "마지막 접속", + "label.leave": "떠나기", + "label.leave-team": "팀 떠나기", + "label.less-than": "미만", + "label.less-than-equals": "이하", + "label.links": "링크", + "label.login": "로그인", + "label.logout": "로그아웃", + "label.manage": "관리", + "label.manager": "관리자", + "label.max": "최대", + "label.maximize": "확장", + "label.medium": "미디엄", + "label.member": "멤버", + "label.members": "멤버", + "label.min": "최소", + "label.mobile": "모바일", + "label.model": "모델", + "label.more": "더 보기", + "label.my-account": "내 계정", + "label.my-websites": "내 웹사이트", + "label.name": "이름", + "label.new-password": "새 비밀번호", + "label.none": "없음", + "label.number-of-records": "{x}개 레코드", + "label.ok": "확인", + "label.online": "Online", + "label.organic-search": "자연 검색", + "label.organic-shopping": "자연 쇼핑", + "label.organic-social": "자연 소셜", + "label.organic-video": "자연 비디오", + "label.os": "운영 체제", + "label.other": "기타", + "label.overview": "개요", + "label.owner": "소유자", + "label.page": "페이지", + "label.page-of": "페이지 {current}/{total}", + "label.page-views": "페이지 조회", + "label.pageTitle": "페이지 제목", + "label.pages": "페이지", + "label.paid-ads": "유료 광고", + "label.paid-search": "유료 검색", + "label.paid-shopping": "유료 쇼핑", + "label.paid-social": "유료 소셜", + "label.paid-video": "유료 비디오", + "label.password": "비밀번호", + "label.path": "패스", + "label.paths": "패스", + "label.pixels": "픽셀", + "label.powered-by": "Powered by {name}", + "label.previous": "이전", + "label.previous-period": "이전 기간", + "label.previous-year": "이전 연도", + "label.profile": "프로필", + "label.properties": "속성", + "label.property": "속성", + "label.queries": "쿼리", + "label.query": "쿼리", + "label.query-parameters": "쿼리 매개 변수", + "label.realtime": "실시간", + "label.referral": "Referral", + "label.referrer": "리퍼러", + "label.referrers": "리퍼러", + "label.refresh": "새로 고침", + "label.regenerate": "다시 생성", + "label.region": "지역", + "label.regions": "지역", + "label.remaining": "남음", + "label.remove": "제거", + "label.remove-member": "멤버 제거", + "label.reports": "보고서", + "label.required": "필수", + "label.reset": "초기화", + "label.reset-website": "웹사이트 초기화", + "label.retention": "리텐션", + "label.retention-description": "사용자가 얼마나 자주 돌아오는지를 추적하여 웹사이트의 리텐션을 측정하세요.", + "label.revenue": "수익", + "label.revenue-description": "시간대별 수익을 살펴보세요.", + "label.role": "역할", + "label.run-query": "쿼리 실행", + "label.save": "저장", + "label.screens": "화면", + "label.search": "검색", + "label.select": "선택", + "label.select-date": "날짜 선택", + "label.select-filter": "필터 선택", + "label.select-role": "역할 선택", + "label.select-website": "웹사이트 선택", + "label.session": "세션", + "label.session-data": "세션 데이터", + "label.sessions": "세션", + "label.settings": "설정", + "label.share": "공유", + "label.share-url": "공유 URL", + "label.single-day": "하루", + "label.sms": "SMS", + "label.sources": "소스", + "label.start-step": "시작 단계", + "label.steps": "단계", + "label.sum": "합계", + "label.tablet": "태블릿", + "label.tag": "태그", + "label.tags": "태그", + "label.team": "팀", + "label.team-id": "팀 ID", + "label.team-manager": "팀 관리자", + "label.team-member": "팀 멤버", + "label.team-name": "팀 이름", + "label.team-owner": "팀 소유자", + "label.team-settings": "팀 설정", + "label.team-view-only": "팀 보기 전용", + "label.team-websites": "팀 웹사이트", + "label.teams": "팀", + "label.terms": "약관", + "label.theme": "테마", + "label.this-month": "이번 달", + "label.this-week": "이번 주", + "label.this-year": "올해", + "label.timezone": "표준 시간대", + "label.title": "제목", + "label.today": "오늘", + "label.toggle-charts": "차트 전환", + "label.total": "합계", + "label.total-records": "전체 레코드", + "label.tracking-code": "추적 코드", + "label.transactions": "거래", + "label.transfer": "전송", + "label.transfer-website": "웹사이트 전송", + "label.true": "참", + "label.type": "유형", + "label.unique": "고유", + "label.unique-visitors": "고유 방문자", + "label.uniqueCustomers": "고유 고객", + "label.unknown": "알 수 없음", + "label.untitled": "제목 없음", + "label.update": "업데이트", + "label.user": "사용자", + "label.username": "사용자 이름", + "label.users": "사용자", + "label.utm": "UTM", + "label.utm-description": "UTM 매개변수를 통해 캠페인을 추적하세요.", + "label.value": "값", + "label.view": "보기", + "label.view-details": "자세히 보기", + "label.view-only": "보기 전용", + "label.views": "조회", + "label.views-per-visit": "방문당 조회", + "label.visit-duration": "방문 시간", + "label.visitors": "방문자", + "label.visits": "방문", + "label.website": "웹사이트", + "label.website-id": "웹사이트 ID", + "label.websites": "웹사이트", + "label.window": "창", + "label.yesterday": "어제", + "message.action-confirmation": "확인을 위해 아래 상자에 {confirmation}을(를) 입력하세요.", + "message.active-users": "현재 방문자 {x}명", + "message.bad-request": "Bad request", + "message.collected-data": "수집된 데이터", + "message.confirm-delete": "{target}을(를) 삭제하시겠습니까?", + "message.confirm-leave": "{target}을(를) 떠나시겠습니까?", + "message.confirm-remove": "{target}을(를) 제거하시겠습니까?", + "message.confirm-reset": "{target}을(를) 초기화하시겠습니까?", + "message.delete-team-warning": "팀을 삭제하면 팀에 등록된 모든 웹사이트도 삭제됩니다.", + "message.delete-website-warning": "관련된 모든 데이터가 삭제됩니다.", + "message.error": "문제가 발생했습니다.", + "message.event-log": "{event} - {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "설정으로 이동", + "message.incorrect-username-password": "사용자 이름 또는 비밀번호를 잘못 입력했습니다.", + "message.invalid-domain": "잘못된 도메인입니다. http/https를 포함하지 마세요.", + "message.min-password-length": "최소 {n}자여야 합니다", + "message.new-version-available": "Umami의 새 버전 {version}을(를) 사용할 수 있습니다!", + "message.no-data-available": "사용할 수 있는 데이터가 없습니다.", + "message.no-event-data": "사용할 수 있는 이벤트 데이터가 없습니다.", + "message.no-match-password": "비밀번호가 일치하지 않습니다.", + "message.no-results-found": "결과를 찾을 수 없습니다.", + "message.no-team-websites": "팀에 웹사이트가 없습니다.", + "message.no-teams": "만든 팀이 없습니다.", + "message.no-users": "사용자가 없습니다.", + "message.no-websites-configured": "설정된 웹사이트가 없습니다.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "페이지를 찾을 수 없음", + "message.reset-website": "이 웹사이트를 초기화하려면 아래 상자에 {confirmation}을(를) 입력하세요.", + "message.reset-website-warning": "이 웹사이트의 모든 통계가 삭제되지만 설정은 그대로 유지됩니다.", + "message.saved": "저장했습니다.", + "message.sever-error": "Server error", + "message.share-url": "아래 링크를 통해 웹사이트의 통계를 누구나 볼 수 있습니다.", + "message.team-already-member": "이미 팀 멤버입니다.", + "message.team-not-found": "팀을 찾을 수 없습니다.", + "message.team-websites-info": "웹사이트는 팀 멤버 누구나 볼 수 있습니다.", + "message.tracking-code": "이 웹사이트의 통계를 추적하려면 다음 코드를 HTML의 <head>...</head> 부분에 추가하세요.", + "message.transfer-team-website-to-user": "이 웹사이트를 당신의 계정으로 전송하시겠습니까?", + "message.transfer-user-website-to-team": "이 웹사이트를 전송받을 팀을 선택하세요.", + "message.transfer-website": "웹사이트 소유권을 계정이나 다른 팀으로 전송합니다.", + "message.triggered-event": "트리거된 이벤트", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "사용자를 삭제했습니다.", + "message.viewed-page": "조회한 페이지", + "message.visitor-log": "{os} {device}에서 {browser}을(를) 사용하는 {country}의 방문자" +} diff --git a/src/lang/lt-LT.json b/src/lang/lt-LT.json new file mode 100644 index 0000000..772fa34 --- /dev/null +++ b/src/lang/lt-LT.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Prieigos kodas", + "label.actions": "Veiksmai", + "label.activity": "Veiklos žurnalas", + "label.add": "Pridėti", + "label.add-board": "Pridėti lentą", + "label.add-description": "Pridėti aprašymą", + "label.add-member": "Pridėti narį", + "label.add-step": "Pridėti žingsnį", + "label.add-website": "Pridėti svetainę", + "label.admin": "Administrator", + "label.affiliate": "Partneris", + "label.after": "Po", + "label.all": "Visi", + "label.all-time": "Visas laikotarpis", + "label.analytics": "Analitika", + "label.apply": "Taikyti", + "label.attribution": "Priskyrimas", + "label.attribution-description": "Žiūrėkite, kaip naudotojai įsitraukia į jūsų rinkodarą ir kas lemia konversijas.", + "label.average": "Vidurkis", + "label.back": "Atgal", + "label.before": "Prieš", + "label.behavior": "Elgsena", + "label.boards": "Lentos", + "label.bounce-rate": "Atmetimo rodiklis", + "label.breakdown": "Išskaidymas", + "label.browser": "Naršyklė", + "label.browsers": "Naršyklės", + "label.campaigns": "Kampanijos", + "label.cancel": "Atšaukti", + "label.change-password": "Pakeisti slaptažodį", + "label.channels": "Kanalai", + "label.cities": "Miestai", + "label.city": "Miestas", + "label.clear-all": "Išvalyti visus", + "label.cohort": "Kohorta", + "label.compare": "Palyginti", + "label.compare-dates": "Palyginti datas", + "label.confirm": "Patvirtinti", + "label.confirm-password": "Patvirtinti slaptažodį", + "label.contains": "Turi", + "label.content": "Turinys", + "label.continue": "Tęsti", + "label.conversion": "Konversija", + "label.conversion-rate": "Konversijos rodiklis", + "label.conversion-step": "Konversijos žingsnis", + "label.count": "Skaičius", + "label.countries": "Šalys", + "label.country": "Šalis", + "label.create": "Sukurti", + "label.create-report": "Kurti ataskaitą", + "label.create-team": "Sukurti komandą", + "label.create-user": "Sukurti vartotoją", + "label.created": "Sukurta", + "label.created-by": "Sukūrė", + "label.currency": "Valiuta", + "label.current": "Dabartinis", + "label.current-password": "Dabartinis slaptažodis", + "label.custom-range": "Pasirinktinis intervalas", + "label.dashboard": "Švieslentė", + "label.data": "Duomenys", + "label.date": "Data", + "label.date-range": "Laikotarpis", + "label.day": "Diena", + "label.default-date-range": "Numatytasis laikotarpis", + "label.delete": "Ištrinti", + "label.delete-report": "Ištrinti ataskaitą", + "label.delete-team": "Ištrinti komandą", + "label.delete-user": "Ištrinti vartotoją", + "label.delete-website": "Ištrinti svetainę", + "label.description": "Aprašymas", + "label.desktop": "Stalinis kompiuteris", + "label.details": "Detalės", + "label.device": "Įrenginys", + "label.devices": "Įrenginiai", + "label.direct": "Tiesioginis", + "label.dismiss": "Gerai", + "label.distinct-id": "Unikalus ID", + "label.does-not-contain": "Neturi", + "label.does-not-include": "Neįtraukia", + "label.doest-not-exist": "Neegzistuoja", + "label.domain": "Domenas", + "label.dropoff": "Atsitraukimas", + "label.edit": "Redaguoti", + "label.edit-dashboard": "Redaguoti švieslentę", + "label.edit-member": "Redaguoti narį", + "label.email": "El. paštas", + "label.enable-share-url": "Įjungti bendrinimą su nuoroda", + "label.end-step": "Paskutinis žingsnis", + "label.entry": "Įėjimo URL", + "label.event": "Įvykis", + "label.event-data": "Įvykių duomenys", + "label.event-name": "Įvykio pavadinimas", + "label.events": "Įvykiai", + "label.exists": "Egzistuoja", + "label.exit": "Išėjimo URL", + "label.false": "Netiesa", + "label.field": "Laukelis", + "label.fields": "Laukeliai", + "label.filter": "Filtruoti", + "label.filter-combined": "Kombinuoti", + "label.filter-raw": "Neapdoroti", + "label.filters": "Filtrai", + "label.first-click": "Pirmas paspaudimas", + "label.first-seen": "Pirmą kartą matyta", + "label.funnel": "Piltuvas", + "label.funnel-description": "Supraskite naudotojų konversijos ir atsitraukimo rodiklius.", + "label.funnels": "Piltuvai", + "label.goal": "Tikslas", + "label.goals": "Tikslai", + "label.goals-description": "Sekite savo tikslus puslapių peržiūroms ir įvykiams.", + "label.greater-than": "Daugiau nei", + "label.greater-than-equals": "Daugiau arba lygu", + "label.grouped": "Grupuota", + "label.hostname": "Pagrindinis kompiuteris", + "label.includes": "Įtraukia", + "label.insight": "Įžvalga", + "label.insights": "Įžvalgos", + "label.insights-description": "Pasinerkite giliau į savo duomenis naudodami segmentus ir filtrus.", + "label.is": "Yra", + "label.is-false": "Yra netiesa", + "label.is-not": "Nėra", + "label.is-not-set": "Nenurodyta", + "label.is-set": "Nustatyta", + "label.is-true": "Yra tiesa", + "label.join": "Prisijungti", + "label.join-team": "Prisijungti į komandą", + "label.journey": "Kelionė", + "label.journey-description": "Sužinokite, kaip naudotojai naršo jūsų svetainėje.", + "label.journeys": "Kelionės", + "label.language": "Kalba", + "label.languages": "Kalbos", + "label.laptop": "Nešiojamas kompiuteris", + "label.last-click": "Paskutinis paspaudimas", + "label.last-days": "{x, plural, =0 {Paskutinės # dienų} zero {Paskutinės # dienų} one {Paskutinė diena} other {Paskutinės # dienos}}", + "label.last-hours": "{x, plural, =0 {Paskutinės # valandų} zero {Paskutinės # valandų} one {Paskutinė # valanda} other {Paskutinės # valandos}}", + "label.last-months": "Paskutiniai {x} mėnesiai", + "label.last-seen": "Paskutinį kartą matyta", + "label.leave": "Išeiti", + "label.leave-team": "Išeiti iš komandos", + "label.less-than": "Mažiau nei", + "label.less-than-equals": "Mažiau arba lygu", + "label.links": "Nuorodos", + "label.login": "Prisijungti", + "label.logout": "Atsijungti", + "label.manage": "Tvarkyti", + "label.manager": "Vadovas", + "label.max": "Maksimumas", + "label.maximize": "Išplėsti", + "label.medium": "Vidutinis", + "label.member": "Narys", + "label.members": "Nariai", + "label.min": "Minimumas", + "label.mobile": "Mobilusis", + "label.model": "Modelis", + "label.more": "Daugiau", + "label.my-account": "Mano paskyra", + "label.my-websites": "Mano svetainės", + "label.name": "Pavadinimas", + "label.new-password": "Naujas slaptažodis", + "label.none": "Nėra", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organinė paieška", + "label.organic-shopping": "Organinis apsipirkimas", + "label.organic-social": "Organinis socialinis", + "label.organic-video": "Organinis vaizdo įrašas", + "label.os": "Operacinės sistemos", + "label.other": "Kita", + "label.overview": "Apžvalga", + "label.owner": "Savininkas", + "label.page": "Puslapis", + "label.page-of": "Puslapis {current} iš {total}", + "label.page-views": "Puslapių peržiūros", + "label.pageTitle": "Puslapio pavadinimas", + "label.pages": "Puslapiai", + "label.paid-ads": "Mokama reklama", + "label.paid-search": "Mokama paieška", + "label.paid-shopping": "Mokamas apsipirkimas", + "label.paid-social": "Mokamas socialinis", + "label.paid-video": "Mokamas vaizdo įrašas", + "label.password": "Slaptažodis", + "label.path": "Kelias", + "label.paths": "Keliai", + "label.pixels": "Pikseliai", + "label.powered-by": "Powered by {name}", + "label.previous": "Ankstesnis", + "label.previous-period": "Ankstesnis laikotarpis", + "label.previous-year": "Ankstesni metai", + "label.profile": "Profilis", + "label.properties": "Savybės", + "label.property": "Savybė", + "label.queries": "Užklausos", + "label.query": "Užklausa", + "label.query-parameters": "Užklausų parametrai", + "label.realtime": "Realiuoju laiku", + "label.referral": "Persiuntimas", + "label.referrer": "Persiuntėjas", + "label.referrers": "Persiuntėjai", + "label.refresh": "Atnaujinti", + "label.regenerate": "Sugeneruoti iš naujo", + "label.region": "Regionas", + "label.regions": "Regionai", + "label.remaining": "Likę", + "label.remove": "Pašalinti", + "label.remove-member": "Pašalinti narį", + "label.reports": "Ataskaitos", + "label.required": "Reikalinga", + "label.reset": "Atstatyti", + "label.reset-website": "Atstatyti statistikos duomenis", + "label.retention": "Išlaikymas", + "label.retention-description": "Išmatuokite, kaip dažnai naudotojai grįžta į jūsų svetainę.", + "label.revenue": "Pajamos", + "label.revenue-description": "Peržiūrėkite savo pajamas laikui bėgant.", + "label.role": "Vaidmuo", + "label.run-query": "Vykdyti užklausą", + "label.save": "Išsaugoti", + "label.screens": "Ekranai", + "label.search": "Ieškoti", + "label.select": "Pasirinkti", + "label.select-date": "Pasirinkti laikotarpį", + "label.select-filter": "Pasirinkti filtrą", + "label.select-role": "Pasirinkti rolę", + "label.select-website": "Pasirinkti svetainę", + "label.session": "Sesija", + "label.session-data": "Sesijos duomenys", + "label.sessions": "Sesijos", + "label.settings": "Nustatymai", + "label.share": "Dalintis", + "label.share-url": "Pasidalinti nuoroda", + "label.single-day": "Viena diena", + "label.sms": "SMS", + "label.sources": "Šaltiniai", + "label.start-step": "Pradžios žingsnis", + "label.steps": "Žingsniai", + "label.sum": "Suma", + "label.tablet": "Planšetė", + "label.tag": "Žyma", + "label.tags": "Žymos", + "label.team": "Komanda", + "label.team-id": "Komandos ID", + "label.team-manager": "Komandos vadovas", + "label.team-member": "Komandos narys", + "label.team-name": "Komandos pavadinimas", + "label.team-owner": "Komandos savininkas", + "label.team-settings": "Komandos nustatymai", + "label.team-view-only": "Tik peržiūra", + "label.team-websites": "Komandos svetainės", + "label.teams": "Komandos", + "label.terms": "Sąlygos", + "label.theme": "Spalvų tema", + "label.this-month": "Šis mėnuo", + "label.this-week": "Ši savaitė", + "label.this-year": "Šie metai", + "label.timezone": "Laiko zona", + "label.title": "Pavadinimas", + "label.today": "Šiandien", + "label.toggle-charts": "Rodyti / slėpti grafikus", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Sekimo kodas", + "label.transactions": "Transactions", + "label.transfer": "Perleisti", + "label.transfer-website": "Perleisti svetainę", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Unikalūs lankytojai", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Nežinoma", + "label.untitled": "Be pavadinimo", + "label.update": "Update", + "label.user": "Vartotojas", + "label.username": "Vartotojo vardas", + "label.users": "Vartotojai", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "Atidaryti", + "label.view-details": "Peržiūrėti detaliau", + "label.view-only": "Tik peržiūrėti", + "label.views": "Peržiūros", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Vidutinė vizito trukmė", + "label.visitors": "Lankytojai", + "label.visits": "Visits", + "label.website": "Svetainė", + "label.website-id": "Svetainės ID", + "label.websites": "Svetainės", + "label.window": "Window", + "label.yesterday": "Vakar", + "message.action-confirmation": "Įrašykite {confirmation} žemiau, kad patvirtintumėte.", + "message.active-users": "{x, plural, =0 {# aktyvių vartotojų} zero {# aktyvių vartotojų} one {# aktyvus vartotojas} other {# aktyvūs vartotojai}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Ar esate tikri, jog norite ištrinti svetainę {target}?", + "message.confirm-leave": "Ar esate tikri, jog norite palikti {target}?", + "message.confirm-remove": "Ar esate tikri, jog norite ištrinti {target}?", + "message.confirm-reset": "Are esate tikri, jog norite atstatyti svetainės {target} statistikos duomenis?", + "message.delete-team-warning": "Ištrinant komandą bus ištrintos ir visos komandos svetainės.", + "message.delete-website-warning": "Visi susiję duomenys taip pat bus ištrinti.", + "message.error": "Kažkas įvyko ne taip.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Eiti į nustatymus", + "message.incorrect-username-password": "Neteisingas vartotojo vardas/slaptažodis.", + "message.invalid-domain": "Klaidingas domenas", + "message.min-password-length": "Reikia bent {n} simbolių", + "message.new-version-available": "Išleista nauja 'Umami' {version} versija!", + "message.no-data-available": "Nėra jokių duomenų.", + "message.no-event-data": "Jokių duomenų apie įvykius nėra.", + "message.no-match-password": "Slaptažodžiai nesutampa", + "message.no-results-found": "Jokių rezultatų nerasta.", + "message.no-team-websites": "Ši komanda neturi jokių svetainių.", + "message.no-teams": "Jūs nesate sukūrę jokių komandų.", + "message.no-users": "Nėra jokių vartotojų.", + "message.no-websites-configured": "Jūs nesate susikonfiguravę jokių svetainių.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Puslapis nerastas.", + "message.reset-website": "Kad atstatyti šią svetainę, įrašykite {confirmation} žemiau, kad patvirtintumėte.", + "message.reset-website-warning": "Visi šios svetainės statistikos duomenys bus ištrinti, bet sekimo kodas išliks nepaliestas.", + "message.saved": "Sėkmingai išsaugota.", + "message.sever-error": "Server error", + "message.share-url": "Tai yra viešai prieinama {target} nuoroda (URL).", + "message.team-already-member": "Jūs jau esate šios komandos narys.", + "message.team-not-found": "Komanda nerasta.", + "message.team-websites-info": "Svetaines gali peržiūrėti bet kas iš šios komandos.", + "message.tracking-code": "Sekimo kodas", + "message.transfer-team-website-to-user": "Perduoti šią svetainę į jūsų paskyrą?", + "message.transfer-user-website-to-team": "Pasirinkite komandą, kuriai norite perduoti šią svetainę.", + "message.transfer-website": "Perduoti svetainės nuosavybę į savo paskyrą arba kitą komandą.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Vartotojas ištrintas.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Lankytojas iš {country}, naudojantis {browser} sistemoje {os} {device}" +} diff --git a/src/lang/mn-MN.json b/src/lang/mn-MN.json new file mode 100644 index 0000000..e9c649d --- /dev/null +++ b/src/lang/mn-MN.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Хандалтын код", + "label.actions": "Үйлдлүүд", + "label.activity": "Үйл ажиллагааны бүртгэл", + "label.add": "Нэмэх", + "label.add-board": "Самбар нэмэх", + "label.add-description": "Тайлбар нэмэх", + "label.add-member": "Гишүүн нэмэх", + "label.add-step": "Алхам нэмэх", + "label.add-website": "Веб нэмэх", + "label.admin": "Админ", + "label.affiliate": "Харьяа", + "label.after": "Хойно", + "label.all": "Бүх", + "label.all-time": "Бүх цаг үеийн", + "label.analytics": "Аналитик", + "label.apply": "Хэрэглэх", + "label.attribution": "Холбогдол", + "label.attribution-description": "Хэрэглэгчид таны маркетингт хэрхэн оролцож, ямар зүйлс хөрвүүлэлтэд нөлөөлж байгааг хараарай.", + "label.average": "Дундаж", + "label.back": "Буцах", + "label.before": "Өмнө", + "label.behavior": "Зан төлөв", + "label.boards": "Самбарууд", + "label.bounce-rate": "Нэг хуудас үзээд гарсан", + "label.breakdown": "Задаргаа", + "label.browser": "Хөтөч", + "label.browsers": "Хөтөч", + "label.campaigns": "Аянууд", + "label.cancel": "Цуцлах", + "label.change-password": "Нууц үг солих", + "label.channels": "Суваг", + "label.cities": "Хотууд", + "label.city": "Хот", + "label.clear-all": "Бүгдийг арилгах", + "label.cohort": "Бүлэг", + "label.compare": "Харьцуулах", + "label.compare-dates": "Огноо харьцуулах", + "label.confirm": "Батлах", + "label.confirm-password": "Шинэ нууц үгээ давтах", + "label.contains": "Агуулах", + "label.content": "Агуулга", + "label.continue": "Үргэлжлүүлэх", + "label.conversion": "Хөрвүүлэлт", + "label.conversion-rate": "Хөрвүүлэлтийн хувь", + "label.conversion-step": "Хөрвүүлэлтийн алхам", + "label.count": "Тоо", + "label.countries": "Улс", + "label.country": "Улс", + "label.create": "Үүсгэх", + "label.create-report": "Тайлан үүсгэх", + "label.create-team": "Баг үүсгэх", + "label.create-user": "Хэрэглэгч үүсгэх", + "label.created": "Үүсгэсэн", + "label.created-by": "Үүсгэсэн", + "label.currency": "Валют", + "label.current": "Одоогийн", + "label.current-password": "Ашиглаж буй нууц үг", + "label.custom-range": "Дурын хугацаа", + "label.dashboard": "Хянах самбар", + "label.data": "Өгөгдөл", + "label.date": "Огноо", + "label.date-range": "Хугацааны муж", + "label.day": "Өдөр", + "label.default-date-range": "Өгөгдмөл хугацааны муж", + "label.delete": "Устгах", + "label.delete-report": "Тайлан устгах", + "label.delete-team": "Баг устгах", + "label.delete-user": "Хэрэглэгч устгах", + "label.delete-website": "Веб устгах", + "label.description": "Тайлбар", + "label.desktop": "Суурин компьютер", + "label.details": "Мэдээлэл", + "label.device": "Төхөөрөмж", + "label.devices": "Төхөөрөмж", + "label.direct": "Шууд", + "label.dismiss": "Үл хэрэгсэх", + "label.distinct-id": "Ялгаатай ID", + "label.does-not-contain": "Агуулахгүй", + "label.does-not-include": "Агуулаагүй", + "label.doest-not-exist": "Байхгүй", + "label.domain": "Домэйн", + "label.dropoff": "Уналт", + "label.edit": "Засах", + "label.edit-dashboard": "Хянах самбар засах", + "label.edit-member": "Гишүүн засах", + "label.email": "Имэйл", + "label.enable-share-url": "Хуваалцах холбоос идэвхжүүлэх", + "label.end-step": "Төгсгөлийн алхам", + "label.entry": "Орох зам", + "label.event": "Үйлдэл", + "label.event-data": "Үйлдлийн өгөгдөл", + "label.event-name": "Үйлдлийн нэр", + "label.events": "Үйлдэл", + "label.exists": "Байгаа", + "label.exit": "Гарах зам", + "label.false": "Худал", + "label.field": "Талбар", + "label.fields": "Талбар", + "label.filter": "Шүүлтүүр", + "label.filter-combined": "Нэгтгэсэн", + "label.filter-raw": "Түүхий", + "label.filters": "Шүүлтүүр", + "label.first-click": "Эхний даралт", + "label.first-seen": "Анх харсан", + "label.funnel": "Цутгал", + "label.funnel-description": "Хэрэглэгчдийн шилжилт, уналтын хэмжээг шинжлэх.", + "label.funnels": "Цутгалууд", + "label.goal": "Зорилго", + "label.goals": "Зорилго", + "label.goals-description": "Хуудас үзсэн болон үйлдлийн зорилгыг мөрдөх.", + "label.greater-than": "Их", + "label.greater-than-equals": "Их буюу тэнцүү", + "label.grouped": "Бүлэглэсэн", + "label.hostname": "Хост нэр", + "label.includes": "Агуулсан", + "label.insight": "Ойлголт", + "label.insights": "Шинжлэх", + "label.insights-description": "Өгөгдлөө хэсэгчлэн хуваах, шүүх байдлаар задлан шинжлэх.", + "label.is": "Бол", + "label.is-false": "Худал байна", + "label.is-not": "Биш", + "label.is-not-set": "Утга оноогоогүй", + "label.is-set": "Утга оноосон", + "label.is-true": "Үнэн байна", + "label.join": "Нэгдэх", + "label.join-team": "Багт нэгдэх", + "label.journey": "Аялал", + "label.journey-description": "Хэрэглэгчид таны цахим хуудсаар хэрхэн шилжиж явсныг шинжлэх.", + "label.journeys": "Аялалууд", + "label.language": "Хэл", + "label.languages": "Хэл", + "label.laptop": "Зөөврийн компьютер", + "label.last-click": "Сүүлийн даралт", + "label.last-days": "Сүүлийн {x} хоног", + "label.last-hours": "Сүүлийн {x} цаг", + "label.last-months": "Сүүлийн {x} сар", + "label.last-seen": "Сүүлд харагдсан", + "label.leave": "Гарах", + "label.leave-team": "Багаас гарах", + "label.less-than": "Бага", + "label.less-than-equals": "Бага буюу тэнцүү", + "label.links": "Холбоосууд", + "label.login": "Нэвтрэх", + "label.logout": "Гарах", + "label.manage": "Удирдах", + "label.manager": "Удирдагч", + "label.max": "Max", + "label.maximize": "Өргөтгөх", + "label.medium": "Дунд", + "label.member": "Гишүүн", + "label.members": "Гишүүд", + "label.min": "Min", + "label.mobile": "Утас", + "label.model": "Загвар", + "label.more": "Цааш", + "label.my-account": "Миний бүртгэл", + "label.my-websites": "Миний вебүүд", + "label.name": "Нэр", + "label.new-password": "Шинэ нууц үг", + "label.none": "Байхгүй", + "label.number-of-records": "{x} {x, plural, one {бичлэг} other {бичлэг}}", + "label.ok": "ЗА", + "label.online": "Online", + "label.organic-search": "Байгалийн хайлт", + "label.organic-shopping": "Байгалийн дэлгүүр", + "label.organic-social": "Байгалийн сошиал", + "label.organic-video": "Байгалийн видео", + "label.os": "OS", + "label.other": "Бусад", + "label.overview": "Тойм", + "label.owner": "Эзэмшигч", + "label.page": "Хуудас", + "label.page-of": "Хуудас {total}-с {current}", + "label.page-views": "Хуудас үзсэн", + "label.pageTitle": "Хуудасны гарчиг", + "label.pages": "Хуудас", + "label.paid-ads": "Төлбөртэй зар", + "label.paid-search": "Төлбөртэй хайлт", + "label.paid-shopping": "Төлбөртэй дэлгүүр", + "label.paid-social": "Төлбөртэй сошиал", + "label.paid-video": "Төлбөртэй видео", + "label.password": "Нууц үг", + "label.path": "Зам", + "label.paths": "Зам", + "label.pixels": "Пиксел", + "label.powered-by": "{name} дээр суурилсан", + "label.previous": "Өмнөх", + "label.previous-period": "Өмнөх үе", + "label.previous-year": "Өмнөх жил", + "label.profile": "Бүртгэл", + "label.properties": "Шинж чанар", + "label.property": "Шинж чанар", + "label.queries": "Query-нүүд", + "label.query": "Query", + "label.query-parameters": "Query параметр", + "label.realtime": "Яг одоо", + "label.referral": "Referral", + "label.referrer": "Чиглүүлэгч", + "label.referrers": "Чиглүүлэгч", + "label.refresh": "Сэргээх", + "label.regenerate": "Дахин үүсгэх", + "label.region": "Бүс", + "label.regions": "Бүсүүд", + "label.remaining": "Үлдсэн", + "label.remove": "Устгах", + "label.remove-member": "Гишүүн хасах", + "label.reports": "Тайлан", + "label.required": "Шаардлагатай", + "label.reset": "Дахин эхлүүлэх", + "label.reset-website": "Тоон үзүүлэлтийг дахин эхлүүлэх", + "label.retention": "Барилт", + "label.retention-description": "Хэрэглэгчид таны веб рүү дахин хандах буюу хэрэглэгчдээ хэр тогтоож буйг хэмжих.", + "label.revenue": "Орлого", + "label.revenue-description": "Цаг хугацааны туршид орлогын өөрчлөлтийг харах.", + "label.role": "Эрх", + "label.run-query": "Query ажиллуулах", + "label.save": "Хадгалах", + "label.screens": "Дэлгэц", + "label.search": "Хайх", + "label.select": "Сонгох", + "label.select-date": "Огноо сонгох", + "label.select-filter": "Шүүлтүүр сонгох", + "label.select-role": "Select role", + "label.select-website": "Веб сонгох", + "label.session": "Session", + "label.session-data": "Сессийн өгөгдөл", + "label.sessions": "Sessions", + "label.settings": "Тохиргоо", + "label.share": "Хуваалцах", + "label.share-url": "Хуваалцах холбоос", + "label.single-day": "Нэг өдөр", + "label.sms": "SMS", + "label.sources": "Эх сурвалжууд", + "label.start-step": "Эхлэх алхам", + "label.steps": "Алхам", + "label.sum": "Нийлбэр", + "label.tablet": "Таблет", + "label.tag": "Таг", + "label.tags": "Тагууд", + "label.team": "Баг", + "label.team-id": "Багийн ID", + "label.team-manager": "Багийн удирдагч", + "label.team-member": "Багийн гишүүн", + "label.team-name": "Багийн нэр", + "label.team-owner": "Багийн эзэмшигч", + "label.team-settings": "Багийн тохиргоо", + "label.team-view-only": "Team view only", + "label.team-websites": "Багийн вебүүд", + "label.teams": "Багууд", + "label.terms": "Нөхцөл", + "label.theme": "Загвар", + "label.this-month": "Энэ сар", + "label.this-week": "Энэ долоо хоног", + "label.this-year": "Энэ жил", + "label.timezone": "Цагийн бүс", + "label.title": "Гарчиг", + "label.today": "Өнөөдөр", + "label.toggle-charts": "Графикийг харуулах/нуух", + "label.total": "Нийт", + "label.total-records": "Нийт мөрийн тоо", + "label.tracking-code": "Мөрдөх код", + "label.transactions": "Transactions", + "label.transfer": "Шилжүүлэх", + "label.transfer-website": "Вебийг шилжүүлэх", + "label.true": "Үнэн", + "label.type": "Төрөл", + "label.unique": "Давхардаагүй", + "label.unique-visitors": "Зочин", + "label.uniqueCustomers": "Давтагдаагүй зочин", + "label.unknown": "Тодорхойгүй", + "label.untitled": "Гарчиггүй", + "label.update": "Шинэчлэх", + "label.user": "Хэрэглэгч", + "label.username": "Хэрэглэгчийн нэр", + "label.users": "Хэрэглэгчид", + "label.utm": "UTM", + "label.utm-description": "UTM параметраар кампанит ажлаа мөрдөх.", + "label.value": "Утга", + "label.view": "Харах", + "label.view-details": "Дэлгэрүүлж харах", + "label.view-only": "Зөвхөн үзэх", + "label.views": "Үзсэн", + "label.views-per-visit": "Зочдын хуудас үзсэн тоо", + "label.visit-duration": "Зочилсон дундаж хугацаа", + "label.visitors": "Зочин", + "label.visits": "Зочилсон", + "label.website": "Веб", + "label.website-id": "Вебийн ID", + "label.websites": "Вебүүд", + "label.window": "Цонх", + "label.yesterday": "Өчигдөр", + "message.action-confirmation": "Доорх хэсэгт {confirmation} гэж бичин баталгаажуулна уу.", + "message.active-users": "одоо {x} {x, plural, one {зочин} other {зочин}} байна", + "message.bad-request": "Bad request", + "message.collected-data": "Цуглуулсан өгөгдөл", + "message.confirm-delete": "Та {target}-г устгахдаа итгэлтэй байна уу?", + "message.confirm-leave": "Та {target}-с гарахдаа итгэлтэй байна уу?", + "message.confirm-remove": "Та {target}-г устгахдаа итгэлтэй байна уу?", + "message.confirm-reset": "Та {target}-н тоон үзүүлэлтүүдийг устгахдаа итгэлтэй байна уу?", + "message.delete-team-warning": "Баг устгах нь мөн түүнд харъяалагдах вебүүдийг устгах болно.", + "message.delete-website-warning": "Энэ вебтэй холбоотой бүх өгөгдөл устах болно.", + "message.error": "Ямар нэг зүйл буруу боллоо.", + "message.event-log": "{url}-д {event}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Тохиргоо руу очих", + "message.incorrect-username-password": "Буруу хэрэглэгчийн нэр/нууц үг.", + "message.invalid-domain": "Буруу домэйн", + "message.min-password-length": "Хамгийн багадаа {n} тэмдэгт", + "message.new-version-available": "Umami-н шинэ хувилбар {version} гарсан байна!", + "message.no-data-available": "Өгөгдөл алга.", + "message.no-event-data": "Үйлдлийн өгөгдөл алга.", + "message.no-match-password": "Нууц үг тохирохгүй байна.", + "message.no-results-found": "Ямар ч үр дүн олдсонгүй.", + "message.no-team-websites": "Энэ багт ямар ч веб алга.", + "message.no-teams": "Та ямар ч баг үүсгээгүй байна.", + "message.no-users": "Хэрэглэгч байхгүй байна.", + "message.no-websites-configured": "Та ямар нэгэн веб тохируулаагүй байна.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Хуудас олдсонгүй.", + "message.reset-website": "Тоон үзүүлэлтийг дахин эхлүүлэхийн тулд доорх хэсэгт {confirmation} гэж бичиж, баталгаажуулна уу.", + "message.reset-website-warning": "Энэ вебийн бүх тоон үзүүлэлтүүдийг устгах болно. Гэхдээ мөрдөх код хэвээрээ үлдэнэ.", + "message.saved": "Хадгалсан.", + "message.sever-error": "Server error", + "message.share-url": "Таны вебийн тоон үзүүлэлтүүд доорх URL дээр нийтэд харагдах болно:", + "message.team-already-member": "Та аль хэдийн энэ багийн гишүүн болсон байна.", + "message.team-not-found": "Баг олдсонгүй.", + "message.team-websites-info": "Вебийг багийн бүх гишүүд үзэж болно.", + "message.tracking-code": "Энэ вебийн хандалтуудыг мөрдөхийн тулд доорх кодыг HTML-нхээ <head>...</head> хэсэгт байрлуулна уу.", + "message.transfer-team-website-to-user": "Энэ вебийг өөрийн бүртгэл рүү шилжүүлэх үү?", + "message.transfer-user-website-to-team": "Энэ вебийг шилжүүлж авах багийг сонгоно уу.", + "message.transfer-website": "Энэ вебийг өөрийн бүртгэл рүү эсвэл багт шилжүүлж авах.", + "message.triggered-event": "Өдөөсөн үйлдэл", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Хэрэглэгч устсан.", + "message.viewed-page": "Үзсэн хуудас", + "message.visitor-log": "{country} улсаас {os} {device} дээр {browser} хөтөч ашиглан орсон" +} diff --git a/src/lang/ms-MY.json b/src/lang/ms-MY.json new file mode 100644 index 0000000..32abd08 --- /dev/null +++ b/src/lang/ms-MY.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Access code", + "label.actions": "Aksi", + "label.activity": "Activity log", + "label.add": "Add", + "label.add-board": "Add board", + "label.add-description": "Add description", + "label.add-member": "Add member", + "label.add-step": "Add step", + "label.add-website": "Tambah laman web", + "label.admin": "Pentadbir", + "label.affiliate": "Affiliate", + "label.after": "After", + "label.all": "Semua", + "label.all-time": "All time", + "label.analytics": "Analytics", + "label.apply": "Apply", + "label.attribution": "Attribution", + "label.attribution-description": "See how users engage with your marketing and what drives conversions.", + "label.average": "Average", + "label.back": "Kembali", + "label.before": "Before", + "label.behavior": "Behavior", + "label.boards": "Boards", + "label.bounce-rate": "Kadar lantunan", + "label.breakdown": "Breakdown", + "label.browser": "Browser", + "label.browsers": "Pelayar web", + "label.campaigns": "Campaigns", + "label.cancel": "Batal", + "label.change-password": "Tukar kata laluan", + "label.channels": "Channels", + "label.cities": "Cities", + "label.city": "City", + "label.clear-all": "Clear all", + "label.cohort": "Cohort", + "label.compare": "Compare", + "label.compare-dates": "Compare dates", + "label.confirm": "Confirm", + "label.confirm-password": "Sahkan kata laluan", + "label.contains": "Contains", + "label.content": "Content", + "label.continue": "Continue", + "label.conversion": "Conversion", + "label.conversion-rate": "Conversion rate", + "label.conversion-step": "Conversion step", + "label.count": "Count", + "label.countries": "Negara", + "label.country": "Country", + "label.create": "Create", + "label.create-report": "Create report", + "label.create-team": "Create team", + "label.create-user": "Create user", + "label.created": "Created", + "label.created-by": "Created By", + "label.currency": "Currency", + "label.current": "Current", + "label.current-password": "Kata laluan semasa", + "label.custom-range": "Julat khas", + "label.dashboard": "Papan pemuka", + "label.data": "Data", + "label.date": "Date", + "label.date-range": "Julat tarikh", + "label.day": "Day", + "label.default-date-range": "Julat tarikh lalai", + "label.delete": "Padam", + "label.delete-report": "Delete report", + "label.delete-team": "Delete team", + "label.delete-user": "Delete user", + "label.delete-website": "Padam laman web", + "label.description": "Description", + "label.desktop": "Desktop", + "label.details": "Details", + "label.device": "Device", + "label.devices": "Peranti", + "label.direct": "Direct", + "label.dismiss": "Ketepikan", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "Does not contain", + "label.does-not-include": "Does not include", + "label.doest-not-exist": "Does not exist", + "label.domain": "Domain", + "label.dropoff": "Dropoff", + "label.edit": "Edit", + "label.edit-dashboard": "Edit dashboard", + "label.edit-member": "Edit member", + "label.email": "Email", + "label.enable-share-url": "Aktifkan url berkongsi", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "Event", + "label.event-data": "Event data", + "label.event-name": "Event name", + "label.events": "Peristiwa", + "label.exists": "Exists", + "label.exit": "Exit URL", + "label.false": "False", + "label.field": "Field", + "label.fields": "Fields", + "label.filter": "Filter", + "label.filter-combined": "Digabungkan", + "label.filter-raw": "Mentah", + "label.filters": "Filters", + "label.first-click": "First click", + "label.first-seen": "First seen", + "label.funnel": "Funnel", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Funnels", + "label.goal": "Goal", + "label.goals": "Goals", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "Greater than", + "label.greater-than-equals": "Greater than or equals", + "label.grouped": "Grouped", + "label.hostname": "Hostname", + "label.includes": "Includes", + "label.insight": "Insight", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "Is", + "label.is-false": "Is false", + "label.is-not": "Is not", + "label.is-not-set": "Is not set", + "label.is-set": "Is set", + "label.is-true": "Is true", + "label.join": "Join", + "label.join-team": "Join team", + "label.journey": "Journey", + "label.journey-description": "Understand how users navigate through your website.", + "label.journeys": "Journeys", + "label.language": "Language", + "label.languages": "Languages", + "label.laptop": "Laptop", + "label.last-click": "Last click", + "label.last-days": "{x} hari lepas", + "label.last-hours": "{x} jam lepas", + "label.last-months": "Last {x} months", + "label.last-seen": "Last seen", + "label.leave": "Leave", + "label.leave-team": "Leave team", + "label.less-than": "Less than", + "label.less-than-equals": "Less than or equals", + "label.links": "Links", + "label.login": "Log masuk", + "label.logout": "Log keluar", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Expand", + "label.medium": "Medium", + "label.member": "Member", + "label.members": "Members", + "label.min": "Min", + "label.mobile": "Telefon bimbit", + "label.model": "Model", + "label.more": "Lebih banyak lagi", + "label.my-account": "My account", + "label.my-websites": "My websites", + "label.name": "Nama", + "label.new-password": "Kata laluan baru", + "label.none": "None", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organic search", + "label.organic-shopping": "Organic shopping", + "label.organic-social": "Organic social", + "label.organic-video": "Organic video", + "label.os": "OS", + "label.other": "Other", + "label.overview": "Overview", + "label.owner": "Owner", + "label.page": "Page", + "label.page-of": "Page {current} of {total}", + "label.page-views": "Paparan halaman", + "label.pageTitle": "Page title", + "label.pages": "Halaman", + "label.paid-ads": "Paid ads", + "label.paid-search": "Paid search", + "label.paid-shopping": "Paid shopping", + "label.paid-social": "Paid social", + "label.paid-video": "Paid video", + "label.password": "Kata laluan", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "Pixels", + "label.powered-by": "Disediakan oleh {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "Profil", + "label.properties": "Properties", + "label.property": "Property", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "Siaran langsung", + "label.referral": "Referral", + "label.referrer": "Referrer", + "label.referrers": "Perujuk", + "label.refresh": "Muat semula", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Remaining", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "Diperlukan", + "label.reset": "Tetapkan semula", + "label.reset-website": "Reset statistics", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Simpan", + "label.screens": "Screens", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Select filter", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Session", + "label.session-data": "Session data", + "label.sessions": "Sessions", + "label.settings": "Tetapan", + "label.share": "Share", + "label.share-url": "Kongsikan URL", + "label.single-day": "Satu hari", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "Tablet", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Team settings", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Terms", + "label.theme": "Theme", + "label.this-month": "Bulan ini", + "label.this-week": "Minggu ini", + "label.this-year": "Tahun ini", + "label.timezone": "Zon masa", + "label.title": "Title", + "label.today": "Hari ini", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Kod penjejakan", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Pelawat unik", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Tidak diketahui", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Nama pengguna", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Lihat butiran", + "label.view-only": "View only", + "label.views": "Lawatan", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Purata tempoh masa lawatan", + "label.visitors": "Pelawat", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Laman web", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} semasa {x, plural, one {pelawat} other {pelawat}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Pastikah anda ingin memadam {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "Semua data yang berkaitan juga akan dihapuskan.", + "message.error": "Ada yang tidak kena.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Pergi ke tetapan", + "message.incorrect-username-password": "Pengguna/kata laluan tidak betul.", + "message.invalid-domain": "Domain tidak sah", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Tiada data yang boleh didapati.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Kata laluan tidak sepadan", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "Anda tidak ada sebarang laman web yang telah dikonfigurasikan.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Halaman tidak dijumpai.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.", + "message.saved": "Berjaya disimpan.", + "message.sever-error": "Server error", + "message.share-url": "Ini adalah URL berkongsi untuk {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Kod penjejakan", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Pelawat dari {country} mengguna {browser} pada {os} {device}" +} diff --git a/src/lang/my-MM.json b/src/lang/my-MM.json new file mode 100644 index 0000000..156b0c2 --- /dev/null +++ b/src/lang/my-MM.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "ဝင်ခွင့်ကုဒ်", + "label.actions": "လုပ်ဆောင်ချက်များ", + "label.activity": "လုပ်ဆောင်ချက်စာရင်း", + "label.add": "ထပ်ထည့်မည်", + "label.add-board": "Add board", + "label.add-description": "အကြောင်းအရာဖော်ပြချက် ထည့်မည်", + "label.add-member": "Add member", + "label.add-step": "Add step", + "label.add-website": "ဝက်ဘ်ဆိုဒ်ထည့်မည်", + "label.admin": "အက်ဒမင်", + "label.affiliate": "Affiliate", + "label.after": "ပြီးနောက်", + "label.all": "အားလုံး", + "label.all-time": "အချိန်အစမှအခုထိ", + "label.analytics": "အန်နလစ်တစ်", + "label.apply": "Apply", + "label.attribution": "Attribution", + "label.attribution-description": "See how users engage with your marketing and what drives conversions.", + "label.average": "ပျမ်းမျှ", + "label.back": "နောက်သို့", + "label.before": "မတိုင်မီ", + "label.behavior": "အပြုအမူ", + "label.boards": "Boards", + "label.bounce-rate": "Bounce နှုန်း", + "label.breakdown": "ခွဲခြမ်းစိတ်ဖြာမှု", + "label.browser": "Browser", + "label.browsers": "ဝက်ဘ်ဘရောင်ဇာများ", + "label.campaigns": "Campaigns", + "label.cancel": "မလုပ်တော့ပါ", + "label.change-password": "စကားဝှက် ပြောင်းမည်", + "label.channels": "Channels", + "label.cities": "မြို့များ", + "label.city": "City", + "label.clear-all": "အားလုံးကိုဖျက်မည်", + "label.cohort": "Cohort", + "label.compare": "Compare", + "label.compare-dates": "Compare dates", + "label.confirm": "အတည်ပြုသည်", + "label.confirm-password": "စကားဝှက်အတည်ပြုသည်", + "label.contains": "ပါဝင်သည်", + "label.content": "Content", + "label.continue": "ဆက်သွားမည်", + "label.conversion": "Conversion", + "label.conversion-rate": "Conversion rate", + "label.conversion-step": "Conversion step", + "label.count": "Count", + "label.countries": "နိုင်ငံများ", + "label.country": "Country", + "label.create": "Create", + "label.create-report": "ရီပို့လုပ်မည်", + "label.create-team": "Team ပြုလုပ်မည်", + "label.create-user": "အသုံးပြုသူထည့်မည်", + "label.created": "ပြုလုပ်ပြီးသော", + "label.created-by": "Created By", + "label.currency": "Currency", + "label.current": "Current", + "label.current-password": "လက်ရှိစကားဝှက်", + "label.custom-range": "အချိန်အပိုင်းအခြားရွေးရန်", + "label.dashboard": "ဒက်ရှ်ဘုတ်", + "label.data": "ဒေတာ", + "label.date": "Date", + "label.date-range": "ရက်အပိုင်းအခြား", + "label.day": "Day", + "label.default-date-range": "ပုံသေ ရက်အပိုင်းအခြား", + "label.delete": "ဖျက်မည်", + "label.delete-report": "Delete report", + "label.delete-team": "Team ကိုဖျက်မည်", + "label.delete-user": "အသုံးပြုသူကိုဖျက်မည်", + "label.delete-website": "ဝက်ဘ်ဆိုဒ်ကိုဖျက်မည်", + "label.description": "ရှင်းပြချက်", + "label.desktop": "စားပွဲတင်ကွန်ပျူတာ", + "label.details": "အသေးစိတ်", + "label.device": "Device", + "label.devices": "အသုံးပြုသည့် ကိရိယာများ", + "label.direct": "Direct", + "label.dismiss": "ပိတ်ပါ", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "မပါဝင်ပါ", + "label.does-not-include": "Does not include", + "label.doest-not-exist": "Does not exist", + "label.domain": "ဒိုမိန်း", + "label.dropoff": "Dropoff", + "label.edit": "ပြုပြင်မည်", + "label.edit-dashboard": "ဒက်ရှ်ဘုတ်ကို ပြုပြင်မည်", + "label.edit-member": "Edit member", + "label.email": "Email", + "label.enable-share-url": "ဝေငှခြင်းကိုလင့်ကို ဖွင့်မည်", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "အဖြစ်အပျက်", + "label.event-data": "အဖြစ်အပျက် ဒေတာ", + "label.event-name": "Event name", + "label.events": "အဖြစ်အပျက်များ", + "label.exists": "Exists", + "label.exit": "Exit URL", + "label.false": "မှားသည်", + "label.field": "Field အမည်", + "label.fields": "Field အမည်များ", + "label.filter": "Filter", + "label.filter-combined": "ပေါင်းစပ်ပြီး", + "label.filter-raw": "အရှိအတိုင်း", + "label.filters": "Filter များ", + "label.first-click": "First click", + "label.first-seen": "First seen", + "label.funnel": "ဖန်နယ်", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Funnels", + "label.goal": "Goal", + "label.goals": "Goals", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "ထက်ပို၍ကြီးသည်", + "label.greater-than-equals": "ထက်ပို၍ကြီးသည်သို့မဟုတ်တူသည်", + "label.grouped": "Grouped", + "label.hostname": "Hostname", + "label.includes": "Includes", + "label.insight": "Insight", + "label.insights": "အသေးစိတ်သိမြင်နိုင်ရန်", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "Is", + "label.is-false": "Is false", + "label.is-not": "Is not", + "label.is-not-set": "Is not set", + "label.is-set": "Is set", + "label.is-true": "Is true", + "label.join": "ဝင်မည်", + "label.join-team": "အသင်းဝင်မည်", + "label.journey": "Journey", + "label.journey-description": "Understand how users navigate through your website.", + "label.journeys": "Journeys", + "label.language": "ဘာသာစကား", + "label.languages": "ဘာသာစကားများ", + "label.laptop": "လက်တော့ပ်", + "label.last-click": "Last click", + "label.last-days": "လွန်ခဲ့သော {x} ရက်က", + "label.last-hours": "လွန်ခဲ့သော {x} နာရီက", + "label.last-months": "Last {x} months", + "label.last-seen": "Last seen", + "label.leave": "ထွက်မည်", + "label.leave-team": "အသင်းမှထွက်မည်", + "label.less-than": "ထက်ပို၍ငယ်သည်", + "label.less-than-equals": "ထက်ပို၍ငယ်သည်သို့မဟုတ်တူသည်", + "label.links": "Links", + "label.login": "လော့ဂ်အင်", + "label.logout": "လော့ဂ်အောက်လုပ်မည်", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "အများဆုံး", + "label.maximize": "Expand", + "label.medium": "Medium", + "label.member": "Member", + "label.members": "အဖွဲ့ဝင်များ", + "label.min": "အနည်းဆုံး", + "label.mobile": "မိုဘိုင်း", + "label.model": "Model", + "label.more": "နောက်ထပ်", + "label.my-account": "My account", + "label.my-websites": "My websites", + "label.name": "အမည်", + "label.new-password": "စကားဝှက်အသစ်", + "label.none": "မရှိပါ", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organic search", + "label.organic-shopping": "Organic shopping", + "label.organic-social": "Organic social", + "label.organic-video": "Organic video", + "label.os": "ကွန်ပျူတာလည်ပတ်မှုစနစ်", + "label.other": "Other", + "label.overview": "အပေါ်ယံမြင်ကွင်း", + "label.owner": "ပိုင်ဆိုင်သူ", + "label.page": "Page", + "label.page-of": "Page {current} of {total}", + "label.page-views": "ဝင်ရောက်ကြည့်ရှုသူ", + "label.pageTitle": "Page title", + "label.pages": "စာမျက်နှာများ", + "label.paid-ads": "Paid ads", + "label.paid-search": "Paid search", + "label.paid-shopping": "Paid shopping", + "label.paid-social": "Paid social", + "label.paid-video": "Paid video", + "label.password": "စကားဝှက်", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "Pixels", + "label.powered-by": "{name} ထောက်ပံ့သည်", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "ပရိုဖိုင်း", + "label.properties": "Properties", + "label.property": "Property", + "label.queries": "Queries (ကွာရီများ)", + "label.query": "Query (ကွာရီ)", + "label.query-parameters": "Query parameters (ကွာရီပါရာမီတာများ)", + "label.realtime": "အချိန်နှင့်တပြေးညီ", + "label.referral": "Referral", + "label.referrer": "Referrer", + "label.referrers": "ရည်ညွှန်းမှုများ", + "label.refresh": "Refresh လုပ်မည်", + "label.regenerate": "ပြန်ထုတ်မည်", + "label.region": "Region", + "label.regions": "ဒေသများ", + "label.remaining": "Remaining", + "label.remove": "ဖျက်မည်", + "label.remove-member": "Remove member", + "label.reports": "တင်ပြမှုများ", + "label.required": "လိုအပ်သည်", + "label.reset": "ပြန်စမည်", + "label.reset-website": "ဝက်ဘ်ဆိုဒ်ဒေတာကိုဖျက်မည်", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "အခန်းကဏ္ဍ", + "label.run-query": "Query ကိုလုပ်ဆောင်မည်", + "label.save": "သိမ်းဆည်းမည်", + "label.screens": "မြင်ကွင်းများ", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "ရက်ရွေးပါ", + "label.select-filter": "Select filter", + "label.select-role": "Select role", + "label.select-website": "ဝဘက်ဘ်ဆိုဒ်ရွေးပါ", + "label.session": "Session", + "label.session-data": "Session data", + "label.sessions": "ဆက်ရှင်များ", + "label.settings": "ဆက်တင်များ", + "label.share": "Share", + "label.share-url": "URL ကိုရှဲမည်", + "label.single-day": "တစ်ရက်အတွင်း", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "ပေါင်းလဒ်", + "label.tablet": "တက်ဘလက်", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "အသင်း", + "label.team-id": "အသင်း အိုင်ဒီ", + "label.team-manager": "Team manager", + "label.team-member": "အသင်းဝင်", + "label.team-name": "Team name", + "label.team-owner": "အသင်းကိုပိုင်ဆိုင်သူ", + "label.team-settings": "Team settings", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "အသင်းများ", + "label.terms": "Terms", + "label.theme": "Theme (အပြင်အဆင်)", + "label.this-month": "ယခုလ", + "label.this-week": "ယခုအပတ်", + "label.this-year": "ယခုနှစ်", + "label.timezone": "အချိန်ဇုန်", + "label.title": "ခေါင်းစဥ်", + "label.today": "ယနေ့", + "label.toggle-charts": "ဇယားများကို အဖွင့်အပိတ်လုပ်မည်", + "label.total": "စုစုပေါင်း", + "label.total-records": "မှတ်တမ်းစုစုပေါင်း", + "label.tracking-code": "ထရက်လုပ်သည့် ကုဒ်", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "မှန်သည်", + "label.type": "အမျိုးအစား", + "label.unique": "Unique", + "label.unique-visitors": "ဝင်ရောက်သူ (ထပ်ခြင်းမရှိ)", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "မသိသော", + "label.untitled": "ခေါင်းစဉ်မရှိ", + "label.update": "Update", + "label.user": "အသုံးပြုသူ", + "label.username": "အသုံးပြုသူအမည်", + "label.users": "အသုံးပြုသူများ", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "တန်ဖိုး", + "label.view": "ဝင်ရောက်ကြည့်ရှုမှု", + "label.view-details": "အသေးစိတ်ကို ကြည့်ရှုမည်", + "label.view-only": "ဝင်ရောက်ကြည့်ရှုမှုများသာ", + "label.views": "ဝင်ရောက်ကြည့်ရှုမှုများ", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "ဝဘက်ဘ်ဆိုဒ်တွင် ပျမ်းမျှကုန်ဆုံးချိန်", + "label.visitors": "ဝင်ရောက်ကြည့်ရှုသူများ", + "label.visits": "Visits", + "label.website": "ဝက်ဘ်ဆိုဒ်", + "label.website-id": "ဝက်ဘ်ဆိုဒ် အိုင်ဒီ", + "label.websites": "ဝက်ဘ်ဆိုဒ်များ", + "label.window": "ဝင်းဒိုး", + "label.yesterday": "မနေ့က", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} လက်ရှိအသုံးပြုနေသူ {x, plural, one {ယောက်} other {ယောက်}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "{target} ကို ဖျက်ရန် သေချာပါသလား?", + "message.confirm-leave": "{target} ကို ထွက်ရန် သေချာပါသလား?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "{target} ကို ဖျက်၍ပြန်စလုပ်ရန် သေချာပါသလား?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "ဝက်ဘ်ဆိုဒ် ဒေတာအကုန် ဖျက်မည်", + "message.error": "မှားယွင်းမှုတစ်ခု ရှိသွားပါသည်", + "message.event-log": "{url} တွင် {event}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "ဆက်တင်သို့ သွားရန်", + "message.incorrect-username-password": "အသုံးပြုသူအမည် သို့မဟုတ် စကားဝှက် မှားနေသည်", + "message.invalid-domain": "ဒိုမိန်း မမှန်ပါ http/https. မပါရပါ", + "message.min-password-length": "အနည်းဆုံး {n} character ရှိရမည်", + "message.new-version-available": "အူမာမီ {version} အသစ်ထွက်နေပါပြီ", + "message.no-data-available": "ဒေတာ မရှိပါ", + "message.no-event-data": "အဖြစ်အပျက်ဒေတာ မရှိပါ", + "message.no-match-password": "စကားဝှက် မှားနေသည်", + "message.no-results-found": "ရလဒ်မရှိပါ", + "message.no-team-websites": "ဤအသင်းတွင် ဝက်ဘ်ဆိုက်မရှိသေးပါ", + "message.no-teams": "အသင်း မပြုလုပ်ရသေးပါ", + "message.no-users": "အသုံးပြုသူ မရှိသေးပါ", + "message.no-websites-configured": "ဝက်ဘ်ဆိုဒ်တစ်ခုမှ မထည့်ရသေးပါ", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "ဤစာမျက်နှာသည် မရှိပါ", + "message.reset-website": "ဤ ဝက်ဘ်ဆိုဒ်ဒေတာကိုဖျက်၍ ပြန်စလုပ်ရန် အောက်တွင် {confirmation} ကို ရိုက်ထည့်ပေးပါ", + "message.reset-website-warning": "ဤဝက်ဘ်ဆိုဒ်က စာရင်းအချက်အလက်များကို ဖျက်မည်၊ ဆက်တင်ဒေတာများ မပါပါ", + "message.saved": "မှတ်သားပြီး", + "message.sever-error": "Server error", + "message.share-url": "သင့်ဝက်ဆိုဒ်ဘ်၏ စာရင်းအချက်အလက်များကို အောက်ပါ URL တွင် ဝင်ရောက်ကြည့်ရှုနိုင်သည်", + "message.team-already-member": "ဤအသင်းတွင် ဝင်ပြီးသားဖြစ်နေသည်", + "message.team-not-found": "အသင်း မရှိပါ", + "message.team-websites-info": "ဤဝက်ဘ်ဆိုဒ်များကို အသင်းထဲမှ လူတိုင်းဝင်ကြည့်နိုင်သည်", + "message.tracking-code": "ဤဝက်ဘ်ဆိုဒ်၏ ဒေတာကိုကောက်ခံရန် အောက်ပါ code ကို သင်၏ HTML တွင်ထည့်ပါ", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "အသုံးပြုသူ ဖျက်ပြီးပါပြီ", + "message.viewed-page": "Viewed page", + "message.visitor-log": "{country} မှ {browser} ဖြင့် {os} {device} တွင် ဝင်ရောက်ကြည့်ရှုသူ" +} diff --git a/src/lang/nb-NO.json b/src/lang/nb-NO.json new file mode 100644 index 0000000..adb4468 --- /dev/null +++ b/src/lang/nb-NO.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Tilgangskode", + "label.actions": "Handlinger", + "label.activity": "Aktivitetslogg", + "label.add": "Legg til", + "label.add-board": "Legg til tavle", + "label.add-description": "Legg til beskrivelse", + "label.add-member": "Legg til bruker", + "label.add-step": "Legg til steg", + "label.add-website": "Legg til nettsted", + "label.admin": "Administrator", + "label.affiliate": "Tilknyttet", + "label.after": "Etter", + "label.all": "Alle", + "label.all-time": "Noensinne", + "label.analytics": "Analyse", + "label.apply": "Bruk", + "label.attribution": "Attribusjon", + "label.attribution-description": "Se hvordan brukere engasjerer seg i markedsføringen din og hva som driver konverteringer.", + "label.average": "Gjennomsnnitt", + "label.back": "Tilbake", + "label.before": "Før", + "label.behavior": "Atferd", + "label.boards": "Tavler", + "label.bounce-rate": "Avvisningsfrekvens", + "label.breakdown": "Nedbrytning", + "label.browser": "Nettleser", + "label.browsers": "Nettlesere", + "label.campaigns": "Kampanjer", + "label.cancel": "Avvis", + "label.change-password": "Bytt passord", + "label.channels": "Kanaler", + "label.cities": "Byer", + "label.city": "By", + "label.clear-all": "Tøm alle", + "label.cohort": "Kohort", + "label.compare": "Sammenlign", + "label.compare-dates": "Sammenlign datoer", + "label.confirm": "Bekreft", + "label.confirm-password": "Godkjenn passord", + "label.contains": "Inneholder", + "label.content": "Innhold", + "label.continue": "Fortsett", + "label.conversion": "Konvertering", + "label.conversion-rate": "Konverteringsrate", + "label.conversion-step": "Konverteringssteg", + "label.count": "Antall", + "label.countries": "Land", + "label.country": "Land", + "label.create": "Opprett", + "label.create-report": "Opprett rapport", + "label.create-team": "Opprett team", + "label.create-user": "Opprett bruker", + "label.created": "Opprettet", + "label.created-by": "Opprettet av", + "label.currency": "Valuta", + "label.current": "Nåværende", + "label.current-password": "Nåværende passord", + "label.custom-range": "Egendefinert utvalg", + "label.dashboard": "Dashbord", + "label.data": "Data", + "label.date": "Dato", + "label.date-range": "Datointervall", + "label.day": "Dag", + "label.default-date-range": "Standard datoperiode", + "label.delete": "Slett", + "label.delete-report": "Slett rapport", + "label.delete-team": "Slett team", + "label.delete-user": "Slett bruker", + "label.delete-website": "Slett nettstedet", + "label.description": "Beskrivelse", + "label.desktop": "Stasjonær", + "label.details": "Detaljer", + "label.device": "Enhet", + "label.devices": "Enheter", + "label.direct": "Direkte", + "label.dismiss": "Avbryt", + "label.distinct-id": "Unik ID", + "label.does-not-contain": "Innholder ikke", + "label.does-not-include": "Inkluderer ikke", + "label.doest-not-exist": "Eksisterer ikke", + "label.domain": "Domene", + "label.dropoff": "Dropoff", + "label.edit": "Rediger", + "label.edit-dashboard": "Rediger dashboard", + "label.edit-member": "Rediger bruker", + "label.email": "Email", + "label.enable-share-url": "Aktiver delings-URL", + "label.end-step": "Avslutt steg", + "label.entry": "Inngangs-URL", + "label.event": "Hendelse", + "label.event-data": "Hendelsesdata", + "label.event-name": "Hendelsesnavn", + "label.events": "Hendelser", + "label.exists": "Eksisterer", + "label.exit": "Utgangs-URL", + "label.false": "Usant", + "label.field": "Felt", + "label.fields": "Felt", + "label.filter": "Filter", + "label.filter-combined": "Kombinert", + "label.filter-raw": "Rå", + "label.filters": "Filter", + "label.first-click": "Første klikk", + "label.first-seen": "Først sett", + "label.funnel": "Trakt", + "label.funnel-description": "Forstå konverteringen og drop-off frafallsfrekvens av brukere.", + "label.funnels": "Trakter", + "label.goal": "Mål", + "label.goals": "Mål", + "label.goals-description": "Spor dine mål for sidevisninger og hendelser.", + "label.greater-than": "Mer enn", + "label.greater-than-equals": "Mer enn eller lik", + "label.grouped": "Gruppert", + "label.hostname": "Vertsnavn", + "label.includes": "Inkluderer", + "label.insight": "Innsikt", + "label.insights": "Innsikt", + "label.insights-description": "Dykk dypere i din data ved bruk av segmentering og filtre.", + "label.is": "Er", + "label.is-false": "Er usant", + "label.is-not": "Er ikke", + "label.is-not-set": "Er ikke satt", + "label.is-set": "Er satt", + "label.is-true": "Er sant", + "label.join": "Bli med", + "label.join-team": "Bli med i teamet", + "label.journey": "Reise", + "label.journey-description": "Forstå hvordan brukerene navigerer gjennom din side.", + "label.journeys": "Reiser", + "label.language": "Språk", + "label.languages": "Språk", + "label.laptop": "Bærbar", + "label.last-click": "Siste klikk", + "label.last-days": "Siste {x} dager", + "label.last-hours": "Siste {x} timer", + "label.last-months": "Last {x} months", + "label.last-seen": "Sist sett", + "label.leave": "Forlat", + "label.leave-team": "Forlat team", + "label.less-than": "Mindre enn", + "label.less-than-equals": "Mindre enn eller lik", + "label.links": "Lenker", + "label.login": "Logg inn", + "label.logout": "Logg ut", + "label.manage": "Administrer", + "label.manager": "Administrator", + "label.max": "Maks", + "label.maximize": "Utvid", + "label.medium": "Medium", + "label.member": "Bruker", + "label.members": "Brukere", + "label.min": "Min", + "label.mobile": "Mobiltelefon", + "label.model": "Modell", + "label.more": "Mer", + "label.my-account": "Min konto", + "label.my-websites": "Mine nettsider", + "label.name": "Navn", + "label.new-password": "Nytt passord", + "label.none": "Ingen", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organisk søk", + "label.organic-shopping": "Organisk handel", + "label.organic-social": "Organisk sosial", + "label.organic-video": "Organisk video", + "label.os": "OS", + "label.other": "Annet", + "label.overview": "Oversikt", + "label.owner": "Eier", + "label.page": "Side", + "label.page-of": "Side {current} av {total}", + "label.page-views": "Sidevisninger", + "label.pageTitle": "Sidetittel", + "label.pages": "Sider", + "label.paid-ads": "Betalte annonser", + "label.paid-search": "Betalt søk", + "label.paid-shopping": "Betalt handel", + "label.paid-social": "Betalt sosial", + "label.paid-video": "Betalt video", + "label.password": "Passord", + "label.path": "Sti", + "label.paths": "Stier", + "label.pixels": "Piksler", + "label.powered-by": "Drevet av {name}", + "label.previous": "Forrige", + "label.previous-period": "Forrige periode", + "label.previous-year": "Forrige år", + "label.profile": "Profil", + "label.properties": "Egenskaper", + "label.property": "Egenskap", + "label.queries": "Forspørsler", + "label.query": "Forespørsel", + "label.query-parameters": "Forespørsel parametere", + "label.realtime": "Sanntid", + "label.referral": "Referral", + "label.referrer": "Henviser", + "label.referrers": "Henvisere", + "label.refresh": "Oppdater", + "label.regenerate": "Regenerer", + "label.region": "Region", + "label.regions": "Regioner", + "label.remaining": "Gjenstår", + "label.remove": "Fjern", + "label.remove-member": "Fjern bruker", + "label.reports": "Rapporter", + "label.required": "Påkrevd", + "label.reset": "Nullstill", + "label.reset-website": "Nullstill statistikk", + "label.retention": "Retensjon", + "label.retention-description": "Mål nettstedets klebrighet ved å spore hvor ofte brukere kommer tilbake.", + "label.revenue": "Inntenker", + "label.revenue-description": "Se på inntektene dine over tid.", + "label.role": "Rolle", + "label.run-query": "Kjør spørring", + "label.save": "Lagre", + "label.screens": "Skjermer", + "label.search": "Søk", + "label.select": "Velg", + "label.select-date": "Velg dato", + "label.select-filter": "Velg filter", + "label.select-role": "Velg rolle", + "label.select-website": "Velg nettsted", + "label.session": "Økt", + "label.session-data": "Øktdata", + "label.sessions": "Økter", + "label.settings": "Innstillinger", + "label.share": "Del", + "label.share-url": "Del URL", + "label.single-day": "Enkeltdag", + "label.sms": "SMS", + "label.sources": "Kilder", + "label.start-step": "Starttrinn", + "label.steps": "Trinn", + "label.sum": "Sum", + "label.tablet": "Nettbrett", + "label.tag": "Tagg", + "label.tags": "Tagger", + "label.team": "Team", + "label.team-id": "Team-ID", + "label.team-manager": "Teamadministrator", + "label.team-member": "Teammedlem", + "label.team-name": "Teamnavn", + "label.team-owner": "Teameier", + "label.team-settings": "Teaminnstillinger", + "label.team-view-only": "Team (kun visning)", + "label.team-websites": "Team-nettsteder", + "label.teams": "Team", + "label.terms": "Vilkår", + "label.theme": "Tema", + "label.this-month": "Denne måneden", + "label.this-week": "Denne uka", + "label.this-year": "I år", + "label.timezone": "Tidssone", + "label.title": "Tittel", + "label.today": "I dag", + "label.toggle-charts": "Veksle grafer", + "label.total": "Totalt", + "label.total-records": "Totalt antall oppføringer", + "label.tracking-code": "Sporingskode", + "label.transactions": "Transaksjoner", + "label.transfer": "Overfør", + "label.transfer-website": "Overfør nettsted", + "label.true": "Sant", + "label.type": "Type", + "label.unique": "Unike", + "label.unique-visitors": "Unike besøkende", + "label.uniqueCustomers": "Unike kunder", + "label.unknown": "Ukjent", + "label.untitled": "Uten tittel", + "label.update": "Oppdater", + "label.user": "Bruker", + "label.username": "Brukernavn", + "label.users": "Brukere", + "label.utm": "UTM", + "label.utm-description": "Spor kampanjene dine via UTM-parametre.", + "label.value": "Verdi", + "label.view": "Vis", + "label.view-details": "Vis detaljer", + "label.view-only": "Kun visning", + "label.views": "Visninger", + "label.views-per-visit": "Visninger per besøk", + "label.visit-duration": "Gjennomsnittlig besøkstid", + "label.visitors": "Besøkende", + "label.visits": "Besøk", + "label.website": "Nettsted", + "label.website-id": "Nettsted-ID", + "label.websites": "Nettsteder", + "label.window": "Vindu", + "label.yesterday": "I går", + "message.action-confirmation": "Skriv {confirmation} i feltet nedenfor for å bekrefte.", + "message.active-users": "{x} {x, plural, one {besøkende} other {besøkende}} nå", + "message.bad-request": "Bad request", + "message.collected-data": "Innsamlede data", + "message.confirm-delete": "Er du sikker på at du vil slette {target}?", + "message.confirm-leave": "Er du sikker på at du vil forlate {target}?", + "message.confirm-remove": "Er du sikker på at du vil fjerne {target}?", + "message.confirm-reset": "Er du sikker på at du vil nullstille statistikken til {target}?", + "message.delete-team-warning": "Å slette et team vil også slette alle teamets nettsteder.", + "message.delete-website-warning": "Alle tilknyttede data vil også bli slettet.", + "message.error": "Noe gikk galt.", + "message.event-log": "{event} på {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Gå til innstillinger", + "message.incorrect-username-password": "Ugyldig brukernavn/passord.", + "message.invalid-domain": "Ugyldig domene", + "message.min-password-length": "Minimumslengde på {n} tegn", + "message.new-version-available": "En ny versjon av Umami {version} er tilgjengelig!", + "message.no-data-available": "Ingen data tilgjengelig.", + "message.no-event-data": "Ingen hendelsesdata er tilgjengelig.", + "message.no-match-password": "Passordene er ikke like", + "message.no-results-found": "Ingen resultater funnet.", + "message.no-team-websites": "Dette teamet har ingen nettsteder.", + "message.no-teams": "Du har ikke opprettet noen team.", + "message.no-users": "Ingen brukere.", + "message.no-websites-configured": "Du har ikke satt opp noen nettsteder.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Siden ble ikke funnet.", + "message.reset-website": "For å nullstille dette nettstedet, skriv {confirmation} i feltet nedenfor for å bekrefte.", + "message.reset-website-warning": "All statistikk for dette nettstedet vil bli slettet, men sporingskoden forblir uberørt.", + "message.saved": "Lagret!", + "message.sever-error": "Server error", + "message.share-url": "Dette er den offentlige delings-URL-en for {target}.", + "message.team-already-member": "Du er allerede medlem av teamet.", + "message.team-not-found": "Teamet ble ikke funnet.", + "message.team-websites-info": "Nettsteder kan vises av alle på teamet.", + "message.tracking-code": "Sporingskode", + "message.transfer-team-website-to-user": "Overfør dette nettstedet til kontoen din?", + "message.transfer-user-website-to-team": "Velg teamet du vil overføre dette nettstedet til.", + "message.transfer-website": "Overfør eierskapet til nettstedet til din konto eller et annet team.", + "message.triggered-event": "Utløst hendelse", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Bruker slettet.", + "message.viewed-page": "Vist side", + "message.visitor-log": "Besøkende fra {country} med {browser} på {os} {device}" +} diff --git a/src/lang/nl-NL.json b/src/lang/nl-NL.json new file mode 100644 index 0000000..1ec5c02 --- /dev/null +++ b/src/lang/nl-NL.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Toegangscode", + "label.actions": "Acties", + "label.activity": "Activiteiten logboek", + "label.add": "Toevoegen", + "label.add-board": "Bord toevoegen", + "label.add-description": "Omschrijving toevoegen", + "label.add-member": "Lid toevoegen", + "label.add-step": "Stap toevoegen", + "label.add-website": "Website koppelen", + "label.admin": "Administrator", + "label.affiliate": "Partner", + "label.after": "Na", + "label.all": "Alles", + "label.all-time": "Onbeperkt", + "label.analytics": "Analyse", + "label.apply": "Toepassen", + "label.attribution": "Toewijzing", + "label.attribution-description": "Bekijk hoe gebruikers omgaan met je marketing en wat conversies stimuleert.", + "label.average": "Gemiddelde", + "label.back": "Terug", + "label.before": "Voor", + "label.behavior": "Gedrag", + "label.boards": "Borden", + "label.bounce-rate": "Bouncepercentage", + "label.breakdown": "Opsplitsen", + "label.browser": "Browser", + "label.browsers": "Browsers", + "label.campaigns": "Campagnes", + "label.cancel": "Annuleren", + "label.change-password": "Wachtwoord wijzigen", + "label.channels": "Kanalen", + "label.cities": "Steden", + "label.city": "Stad", + "label.clear-all": "Filters wissen", + "label.cohort": "Cohort", + "label.compare": "Vergelijken", + "label.compare-dates": "Datums vergelijken", + "label.confirm": "Bevestigen", + "label.confirm-password": "Wachtwoord bevestigen", + "label.contains": "Bevat", + "label.content": "Inhoud", + "label.continue": "Doorgaan", + "label.conversion": "Conversie", + "label.conversion-rate": "Conversieratio", + "label.conversion-step": "Conversiestap", + "label.count": "Aantal", + "label.countries": "Landen", + "label.country": "Land", + "label.create": "Aanmaken", + "label.create-report": "Rapport aanmaken", + "label.create-team": "Team aanmaken", + "label.create-user": "Gebruiker maken", + "label.created": "Gemaakt", + "label.created-by": "Gemaakt Door", + "label.currency": "Valuta", + "label.current": "Huidig", + "label.current-password": "Huidig wachtwoord", + "label.custom-range": "Aangepast bereik", + "label.dashboard": "Overzicht", + "label.data": "Gegevens", + "label.date": "Datum", + "label.date-range": "Datumbereik", + "label.day": "Dag", + "label.default-date-range": "Standaard bereik", + "label.delete": "Verwijderen", + "label.delete-report": "Rapport verwijderen", + "label.delete-team": "Team verwijderen", + "label.delete-user": "Gebruiker verwijderen", + "label.delete-website": "Website verwijderen", + "label.description": "Beschrijving", + "label.desktop": "Computer", + "label.details": "Informatie", + "label.device": "Apparaat", + "label.devices": "Apparaten", + "label.direct": "Direct", + "label.dismiss": "Negeren", + "label.distinct-id": "Uniek ID", + "label.does-not-contain": "Bevat geen", + "label.does-not-include": "Bevat niet", + "label.doest-not-exist": "Bestaat niet", + "label.domain": "Domein", + "label.dropoff": "Uitval", + "label.edit": "Bewerken", + "label.edit-dashboard": "Dashboard aanpassen", + "label.edit-member": "Gebruiker aanpassen", + "label.email": "Email", + "label.enable-share-url": "Sta delen via openbare URL toe", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "Gebeurtenis", + "label.event-data": "Datum gebeurtenis", + "label.event-name": "Gebeurtenisnaam", + "label.events": "Gebeurtenissen", + "label.exists": "Bestaat", + "label.exit": "Exit URL", + "label.false": "Onwaar", + "label.field": "Veld", + "label.fields": "Velden", + "label.filter": "Filter", + "label.filter-combined": "Gecombineerd", + "label.filter-raw": "Ruw", + "label.filters": "Filters", + "label.first-click": "Eerste klik", + "label.first-seen": "First seen", + "label.funnel": "Funnel", + "label.funnel-description": "Ontdek de conversie- en uitvalpercentages van gebruikers.", + "label.funnels": "Trechters", + "label.goal": "Doel", + "label.goals": "Doelen", + "label.goals-description": "Volg je doelen voor paginaweergaven en gebeurtenissen.", + "label.greater-than": "Groter dan", + "label.greater-than-equals": "Groter of gelijk aan", + "label.grouped": "Gegroepeerd", + "label.hostname": "Hostnaam", + "label.includes": "Bevat", + "label.insight": "Inzicht", + "label.insights": "Inzichten", + "label.insights-description": "Verken je gegevens verder door segmenten en filters te gebruiken.", + "label.is": "Is", + "label.is-false": "Is onwaar", + "label.is-not": "Is niet", + "label.is-not-set": "Is niet ingesteld", + "label.is-set": "Is ingesteld", + "label.is-true": "Is waar", + "label.join": "Lid worden", + "label.join-team": "Word lid van een team", + "label.journey": "Reis", + "label.journey-description": "Begrijp hoe gebruikers door je website navigeren.", + "label.journeys": "Reizen", + "label.language": "Taal", + "label.languages": "Talen", + "label.laptop": "Laptop", + "label.last-click": "Laatste klik", + "label.last-days": "Laatste {x} dagen", + "label.last-hours": "Laatste {x} uur", + "label.last-months": "Laatste {x} maanden", + "label.last-seen": "Laatst gezien", + "label.leave": "Verlaten", + "label.leave-team": "Verlaat team", + "label.less-than": "Minder dan", + "label.less-than-equals": "Minder of gelijk aan", + "label.links": "Koppelingen", + "label.login": "Inloggen", + "label.logout": "Uitloggen", + "label.manage": "Beheren", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Uitvouwen", + "label.medium": "Medium", + "label.member": "Gebruiker", + "label.members": "Gebruikers", + "label.min": "Min", + "label.mobile": "Mobiel", + "label.model": "Model", + "label.more": "Toon meer", + "label.my-account": "Mijn profiel", + "label.my-websites": "Mijn websites", + "label.name": "Naam", + "label.new-password": "Nieuw wachtwoord", + "label.none": "Geen", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organisch zoeken", + "label.organic-shopping": "Organisch winkelen", + "label.organic-social": "Organisch sociaal", + "label.organic-video": "Organische video", + "label.os": "OS", + "label.other": "Overig", + "label.overview": "Overzicht", + "label.owner": "Eigenaar", + "label.page": "Pagina", + "label.page-of": "Pagina {current} van {total}", + "label.page-views": "Paginaweergaven", + "label.pageTitle": "Pagina titel", + "label.pages": "Pagina's", + "label.paid-ads": "Betaalde advertenties", + "label.paid-search": "Betaald zoeken", + "label.paid-shopping": "Betaald winkelen", + "label.paid-social": "Betaald sociaal", + "label.paid-video": "Betaalde video", + "label.password": "Wachtwoord", + "label.path": "Pad", + "label.paths": "Paden", + "label.pixels": "Pixels", + "label.powered-by": "mogelijk gemaakt door {name}", + "label.previous": "Vorige", + "label.previous-period": "Vorige periode", + "label.previous-year": "Vorig jaar", + "label.profile": "Profiel", + "label.properties": "Eigenschappen", + "label.property": "Eigenschap", + "label.queries": "Parameters", + "label.query": "Query", + "label.query-parameters": "URL-parameters", + "label.realtime": "Actueel", + "label.referral": "Verwijzing", + "label.referrer": "Referrer", + "label.referrers": "Verwijzers", + "label.refresh": "Vernieuwen", + "label.regenerate": "Opnieuw genereren", + "label.region": "Regio", + "label.regions": "Regio's", + "label.remaining": "Resterend", + "label.remove": "Verwijderen", + "label.remove-member": "Gebruiker verwijderen", + "label.reports": "Rapporten", + "label.required": "Verplicht", + "label.reset": "Opnieuw instellen", + "label.reset-website": "Statistieken opnieuw instellen", + "label.retention": "Retentie", + "label.retention-description": "Meet de retentie van je website door door bij te houden hoe vaak gebruikers terugkeren.", + "label.revenue": "Omzet", + "label.revenue-description": "Bekijk je omzet in de loop van de tijd.", + "label.role": "Gebruikersrol", + "label.run-query": "Query uitvoeren", + "label.save": "Opslaan", + "label.screens": "Schermen", + "label.search": "Zoeken", + "label.select": "Selecteer", + "label.select-date": "Datum selecteren", + "label.select-filter": "Filter selecteren", + "label.select-role": "Rol selecteren", + "label.select-website": "Website selecteren", + "label.session": "Sessie", + "label.session-data": "Sessiegegevens", + "label.sessions": "Sessies", + "label.settings": "Instellingen", + "label.share": "Delen", + "label.share-url": "URL delen", + "label.single-day": "Enkele dag", + "label.sms": "SMS", + "label.sources": "Bronnen", + "label.start-step": "Startstap", + "label.steps": "Stappen", + "label.sum": "Som", + "label.tablet": "Tablet", + "label.tag": "Label", + "label.tags": "Labels", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Teamleider", + "label.team-member": "Teamlid", + "label.team-name": "Teamnaam", + "label.team-owner": "Teameigenaar", + "label.team-settings": "Teaminstellingen", + "label.team-view-only": "Team alleen lezen", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Voorwaarden", + "label.theme": "Thema", + "label.this-month": "Deze maand", + "label.this-week": "Deze week", + "label.this-year": "Dit jaar", + "label.timezone": "Tijdzone", + "label.title": "Titel", + "label.today": "Vandaag", + "label.toggle-charts": "Grafieken tonen/verbergen", + "label.total": "Totaal", + "label.total-records": "Totaal records", + "label.tracking-code": "Volgcode", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "Waar", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Unieke bezoekers", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Onbekend", + "label.untitled": "Ongetiteld", + "label.update": "Update", + "label.user": "Gebruiker", + "label.username": "Gebruikersnaam", + "label.users": "Gebruikers", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Waarde", + "label.view": "Weergave", + "label.view-details": "Meer details", + "label.view-only": "Alleen inzien", + "label.views": "Weergaven", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Gemiddelde bezoektijd", + "label.visitors": "Bezoekers", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Websites", + "label.window": "Window", + "label.yesterday": "Gisteren", + "message.action-confirmation": "Typ {confirmation} in het veld hieronder om te bevestigen.", + "message.active-users": "{x} actieve {x, plural, one {bezoeker} other {bezoekers}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Weet je zeker dat je {target} wilt verwijderen?", + "message.confirm-leave": "Weet je zeker dat je {target} wilt verlaten?", + "message.confirm-remove": "Weet je zeker dat je {target} wilt verwijderen?", + "message.confirm-reset": "Weet je zeker dat je de statistieken van {target} opnieuw wilt instellen?", + "message.delete-team-warning": "Als een team wordt verwijderd, worden ook alle websites van dat team verwijderd.", + "message.delete-website-warning": "Alle verwante gegevens zullen ook verwijderd worden.", + "message.error": "Er is iets misgegaan.", + "message.event-log": "{event} op {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Naar instellingen", + "message.incorrect-username-password": "Incorrecte gebruikersnaam/wachtwoord.", + "message.invalid-domain": "Ongeldig domein", + "message.min-password-length": "Minimale lengte van {n} tekens", + "message.new-version-available": "Een nieuwe versie van Umami {version} is beschikbaar!", + "message.no-data-available": "Geen gegevens beschikbaar.", + "message.no-event-data": "Geen gegevens over de gebeurtenis beschikbaar.", + "message.no-match-password": "Wachtwoorden komen niet overeen", + "message.no-results-found": "Geen resultaten gevonden.", + "message.no-team-websites": "Er zijn geen websites gekoppeld aan dit team.", + "message.no-teams": "Er zijn nog geen teams aangemaakt.", + "message.no-users": "Er zijn geen gebruikers.", + "message.no-websites-configured": "Je hebt geen websites ingesteld.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Pagina niet gevonden.", + "message.reset-website": "Typ {confirmation} in het veld hieronder om te bevestigen dat je de website wilt resetten.", + "message.reset-website-warning": "Alle bijhorende statistieken van deze website worden verwijderd, maar jouw volgcode blijft gelden.", + "message.saved": "Opslaan succesvol.", + "message.sever-error": "Server error", + "message.share-url": "Met deze URL kan {target} openbaar gedeeld worden.", + "message.team-already-member": "Je bent al lid van het team.", + "message.team-not-found": "Team niet gevonden.", + "message.team-websites-info": "Websites kunnen door iedereen in het team worden bekeken.", + "message.tracking-code": "Volgcode", + "message.transfer-team-website-to-user": "Deze website toevoegen aan je account?", + "message.transfer-user-website-to-team": "Selecteer het team om deze website aan toe te voegen.", + "message.transfer-website": "Draag het eigenaarschap van de website over naar jouw account, of een ander team.", + "message.triggered-event": "Getriggerde gebeurtenis", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Gebruiker verwijderd", + "message.viewed-page": "Bekeken pagina", + "message.visitor-log": "Bezoeker uit {country} met {browser} op een {os} {device}" +} diff --git a/src/lang/pl-PL.json b/src/lang/pl-PL.json new file mode 100644 index 0000000..0c8b000 --- /dev/null +++ b/src/lang/pl-PL.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Kod dostępu", + "label.actions": "Działania", + "label.activity": "Dziennik aktywności", + "label.add": "Dodaj", + "label.add-board": "Dodaj tablicę", + "label.add-description": "Dodaj opis", + "label.add-member": "Dodaj członka", + "label.add-step": "Dodaj krok", + "label.add-website": "Dodaj witrynę", + "label.admin": "Administrator", + "label.affiliate": "Partner", + "label.after": "Po", + "label.all": "Wszystkie", + "label.all-time": "Cały czas", + "label.analytics": "Analityka", + "label.apply": "Zastosuj", + "label.attribution": "Atrybucja", + "label.attribution-description": "Zobacz, jak użytkownicy angażują się w Twoją reklamę i co napędza konwersje.", + "label.average": "Średnia", + "label.back": "Powrót", + "label.before": "Przed", + "label.behavior": "Zachowanie", + "label.boards": "Tablice", + "label.bounce-rate": "Współczynnik odrzuceń", + "label.breakdown": "Rozbicie", + "label.browser": "Przeglądarka", + "label.browsers": "Przeglądarki", + "label.campaigns": "Kampanie", + "label.cancel": "Anuluj", + "label.change-password": "Zmień hasło", + "label.channels": "Kanały", + "label.cities": "Miasta", + "label.city": "Miasto", + "label.clear-all": "Wyczyść wszystko", + "label.cohort": "Kohorta", + "label.compare": "Porównaj", + "label.compare-dates": "Porównaj daty", + "label.confirm": "Potwierdź", + "label.confirm-password": "Potwierdź hasło", + "label.contains": "Zawiera", + "label.content": "Treść", + "label.continue": "Kontynuuj", + "label.conversion": "Konwersja", + "label.conversion-rate": "Wskaźnik konwersji", + "label.conversion-step": "Etap konwersji", + "label.count": "Liczba", + "label.countries": "Kraje", + "label.country": "Państwo", + "label.create": "Utwórz", + "label.create-report": "Utwórz raport", + "label.create-team": "Utwórz zespół", + "label.create-user": "Utwórz użytkownika", + "label.created": "Utworzony", + "label.created-by": "Utworzony przez", + "label.currency": "Waluta", + "label.current": "Aktualny", + "label.current-password": "Aktualne hasło", + "label.custom-range": "Zakres niestandardowy", + "label.dashboard": "Panel", + "label.data": "Dane", + "label.date": "Data", + "label.date-range": "Zakres dat", + "label.day": "Dzień", + "label.default-date-range": "Domyślny zakres dat", + "label.delete": "Usuń", + "label.delete-report": "Usuń raport", + "label.delete-team": "Usuń zespół", + "label.delete-user": "Usuń użytkownika", + "label.delete-website": "Usuń witrynę", + "label.description": "Opis", + "label.desktop": "Komputer", + "label.details": "Szczegóły", + "label.device": "Urządzenie", + "label.devices": "Urządzenia", + "label.direct": "Bezpośredni", + "label.dismiss": "Odrzuć", + "label.distinct-id": "Unikalny ID", + "label.does-not-contain": "Nie zawiera", + "label.does-not-include": "Nie zawiera", + "label.doest-not-exist": "Nie istnieje", + "label.domain": "Domena", + "label.dropoff": "Odpływ", + "label.edit": "Edytuj", + "label.edit-dashboard": "Edytuj panel", + "label.edit-member": "Edytuj członka", + "label.email": "Email", + "label.enable-share-url": "Włącz udostępnianie adresu URL", + "label.end-step": "Krok końcowy", + "label.entry": "Entry URL", + "label.event": "Zdarzenie", + "label.event-data": "Dane zdarzenia", + "label.event-name": "Nazwa zdarzenia", + "label.events": "Zdarzenia", + "label.exists": "Istnieje", + "label.exit": "URL wyjściowy", + "label.false": "Fałsz", + "label.field": "Pole", + "label.fields": "Pola", + "label.filter": "Filtruj", + "label.filter-combined": "Połączone", + "label.filter-raw": "Surowe dane", + "label.filters": "Filtry", + "label.first-click": "Pierwsze kliknięcie", + "label.first-seen": "First seen", + "label.funnel": "Lejek", + "label.funnel-description": "Zrozum wskaźniki konwersji i odpływu użytkowników.", + "label.funnels": "Lejki", + "label.goal": "Cel", + "label.goals": "Cele", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "Większe niż", + "label.greater-than-equals": "Większe niż lub równe", + "label.grouped": "Grupowane", + "label.hostname": "Nazwa hosta", + "label.includes": "Zawiera", + "label.insight": "Wgląd", + "label.insights": "Analiza", + "label.insights-description": "Poznaj lepiej swoje dane, korzystając z segmentów i filtrów.", + "label.is": "Równe", + "label.is-false": "Jest fałszem", + "label.is-not": "Nie jest równe", + "label.is-not-set": "Nieustawione", + "label.is-set": "Ustawione", + "label.is-true": "Jest prawdą", + "label.join": "Dołącz", + "label.join-team": "Dołącz do zespołu", + "label.journey": "Droga", + "label.journey-description": "Zrozum, w jaki sposób użytkownicy poruszają się po Twojej witrynie.", + "label.journeys": "Drogi", + "label.language": "Język", + "label.languages": "Języki", + "label.laptop": "Laptop", + "label.last-click": "Ostatnie kliknięcie", + "label.last-days": "Ostatnie {x} dni", + "label.last-hours": "Ostatnie {x} godzin", + "label.last-months": "Ostatnie {x} miesięcy", + "label.last-seen": "Ostatnio widziany", + "label.leave": "Opuść", + "label.leave-team": "Opuść zespół", + "label.less-than": "Mniejsze niż", + "label.less-than-equals": "Mniejsze niż lub równe", + "label.links": "Linki", + "label.login": "Zaloguj się", + "label.logout": "Wyloguj", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "Maks", + "label.maximize": "Rozwiń", + "label.medium": "Medium", + "label.member": "Członek", + "label.members": "Członkowie", + "label.min": "Min", + "label.mobile": "Smartfon", + "label.model": "Model", + "label.more": "Więcej", + "label.my-account": "Moje konto", + "label.my-websites": "Moje witryny", + "label.name": "Nazwa", + "label.new-password": "Nowe hasło", + "label.none": "Brak", + "label.number-of-records": "{x} {x, plural, one {rekord} other {rekordy}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Wyszukiwanie organiczne", + "label.organic-shopping": "Zakupy organiczne", + "label.organic-social": "Organiczne social media", + "label.organic-video": "Organiczne wideo", + "label.os": "OS", + "label.other": "Inne", + "label.overview": "Przegląd", + "label.owner": "Właściciel", + "label.page": "Strona", + "label.page-of": "Strona {current} z {total}", + "label.page-views": "Wyświetlenia strony", + "label.pageTitle": "Tytuł strony", + "label.pages": "Strony", + "label.paid-ads": "Reklamy płatne", + "label.paid-search": "Płatne wyszukiwanie", + "label.paid-shopping": "Płatne zakupy", + "label.paid-social": "Płatne social media", + "label.paid-video": "Płatne wideo", + "label.password": "Hasło", + "label.path": "Ścieżka", + "label.paths": "Ścieżki", + "label.pixels": "Piksele", + "label.powered-by": "Obsługiwane przez {name}", + "label.previous": "Poprzedni", + "label.previous-period": "Poprzedni okres", + "label.previous-year": "Poprzedni rok", + "label.profile": "Profil", + "label.properties": "Właściwości", + "label.property": "Właściwość", + "label.queries": "Zapytania", + "label.query": "Zapytanie", + "label.query-parameters": "Parametry zapytania", + "label.realtime": "Czas rzeczywisty", + "label.referral": "Polecenie", + "label.referrer": "Źródło odsyłające", + "label.referrers": "Źródła odsyłające", + "label.refresh": "Odśwież", + "label.regenerate": "Wygeneruj ponownie", + "label.region": "Region", + "label.regions": "Regiony", + "label.remaining": "Pozostało", + "label.remove": "Usuń", + "label.remove-member": "Usuń członka", + "label.reports": "Raporty", + "label.required": "Wymagany", + "label.reset": "Zresetuj", + "label.reset-website": "Zresetuj statystyki", + "label.retention": "Retencja", + "label.retention-description": "Mierz przyciągającą siłę swojej strony internetowej, śledząc, jak często użytkownicy powracają.", + "label.revenue": "Przychód", + "label.revenue-description": "Sprawdź swoje przychody w czasie.", + "label.role": "Rola", + "label.run-query": "Uruchom zapytanie", + "label.save": "Zapisz", + "label.screens": "Ekrany", + "label.search": "Szukaj", + "label.select": "Wybierz", + "label.select-date": "Wybierz datę", + "label.select-filter": "Wybierz filtr", + "label.select-role": "Wybierz rolę", + "label.select-website": "Wybierz witrynę", + "label.session": "Sesja", + "label.session-data": "Dane sesji", + "label.sessions": "Sesje", + "label.settings": "Ustawienia", + "label.share": "Udostępnij", + "label.share-url": "Udostępnij adres URL", + "label.single-day": "W tym dniu", + "label.sms": "SMS", + "label.sources": "Źródła", + "label.start-step": "Krok startowy", + "label.steps": "Kroki", + "label.sum": "Suma", + "label.tablet": "Tablet", + "label.tag": "Tag", + "label.tags": "Tagi", + "label.team": "Zespół", + "label.team-id": "ID zespołu", + "label.team-manager": "Menedżer zespołu", + "label.team-member": "Członek zespołu", + "label.team-name": "Nazwa zespołu", + "label.team-owner": "Właściciel zespołu", + "label.team-settings": "Ustawienia zespołu", + "label.team-view-only": "Tylko do odczytu dla zespołu", + "label.team-websites": "Witryny zespołu", + "label.teams": "Zespoły", + "label.terms": "Warunki", + "label.theme": "Motyw", + "label.this-month": "W tym miesiącu", + "label.this-week": "W tym tygodniu", + "label.this-year": "W tym roku", + "label.timezone": "Strefa czasowa", + "label.title": "Tytuł", + "label.today": "Dzisiaj", + "label.toggle-charts": "Przełącz wykresy", + "label.total": "W sumie", + "label.total-records": "Suma rekordów", + "label.tracking-code": "Kod śledzenia", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "Prawda", + "label.type": "Typ", + "label.unique": "Unikalne", + "label.unique-visitors": "Unikalni odwiedzający", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Nieznany", + "label.untitled": "Bez tytułu", + "label.update": "Aktualizuj", + "label.user": "Użytkownik", + "label.username": "Nazwa użytkownika", + "label.users": "Użytkownicy", + "label.utm": "UTM", + "label.utm-description": "Śledź swoje kampanie za pomocą parametrów UTM.", + "label.value": "Wartość", + "label.view": "Zobacz", + "label.view-details": "Pokaż szczegóły", + "label.view-only": "Tylko do odczytu", + "label.views": "Wyświetlenia", + "label.views-per-visit": "Widoków na wizytę", + "label.visit-duration": "Średni czas wizyty", + "label.visitors": "Odwiedzający", + "label.visits": "Wizyty", + "label.website": "Witryna", + "label.website-id": "ID witryny", + "label.websites": "Witryny", + "label.window": "Okno", + "label.yesterday": "Wczoraj", + "message.action-confirmation": "Wpisz {confirmation}, aby potwierdzić.", + "message.active-users": "{x} aktualnie {x, plural, one {odwiedzający} other {odwiedzających}}", + "message.bad-request": "Bad request", + "message.collected-data": "Zebrane dane", + "message.confirm-delete": "Czy na pewno chcesz usunąć {target}?", + "message.confirm-leave": "Czy na pewno chcesz opuścić {target}?", + "message.confirm-remove": "Czy na pewno chcesz usunąć {target}?", + "message.confirm-reset": "Czy na pewno chcesz zresetować statystyki {target}?", + "message.delete-team-warning": "Usunięcie zespołu usunie wszystkie jego witryny.", + "message.delete-website-warning": "Wszystkie powiązane dane również zostaną usunięte.", + "message.error": "Coś poszło nie tak.", + "message.event-log": "{event} na {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Przejdź do ustawień", + "message.incorrect-username-password": "Nieprawidłowa nazwa użytkownika lub hasło.", + "message.invalid-domain": "Nieprawidłowa witryna", + "message.min-password-length": "Minimalna długość {n} znaków", + "message.new-version-available": "Nowa wersja Umami {version} jest dostępna!", + "message.no-data-available": "Brak dostępnych danych.", + "message.no-event-data": "Brak dostępnych danych o zdarzeniach.", + "message.no-match-password": "Hasła się nie zgadzają", + "message.no-results-found": "Nie znaleziono wyników.", + "message.no-team-websites": "Ten zespół nie ma żadnych witryn internetowych.", + "message.no-teams": "Nie stworzyłeś żadnych zespołów.", + "message.no-users": "Nie ma żadnych użytkowników.", + "message.no-websites-configured": "Nie masz skonfigurowanych żadnych witryn internetowych.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Strona nie znaleziona.", + "message.reset-website": "Aby zresetować tę witrynę, wpisz {confirmation} w polu poniżej, aby potwierdzić.", + "message.reset-website-warning": "Wszystkie statystyki tej witryny zostaną usunięte, ale kod śledzenia pozostanie nienaruszony.", + "message.saved": "Zapisano pomyślnie.", + "message.sever-error": "Server error", + "message.share-url": "To jest publicznie udostępniany adres URL dla {target}.", + "message.team-already-member": "Jesteś już członkiem zespołu.", + "message.team-not-found": "Nie znaleziono zespołu.", + "message.team-websites-info": "Strony internetowe mogą być przeglądane przez każdego członka zespołu.", + "message.tracking-code": "Kod śledzenia", + "message.transfer-team-website-to-user": "Czy przenieść tę witrynę do Twoje konta?", + "message.transfer-user-website-to-team": "Wybierz zespół, do którego chcesz przenieść tę witrynę.", + "message.transfer-website": "Przenieś własność witryny na swoje konto lub do innego zespołu.", + "message.triggered-event": "Zdarzenie wyzwalające", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Użytkownik usunięty.", + "message.viewed-page": "Obejrzana strona", + "message.visitor-log": "Odwiedzający z {country} używa {browser} na {os} {device}" +} diff --git a/src/lang/pt-BR.json b/src/lang/pt-BR.json new file mode 100644 index 0000000..c34c9ab --- /dev/null +++ b/src/lang/pt-BR.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Código de acesso", + "label.actions": "Ações do usuário", + "label.activity": "Registro de atividades", + "label.add": "Adicionar", + "label.add-board": "Adicionar quadro", + "label.add-description": "Adicionar descrição", + "label.add-member": "Adicionar membro", + "label.add-step": "Adicionar etapa", + "label.add-website": "Adicionar site", + "label.admin": "Administrador", + "label.affiliate": "Afiliado", + "label.after": "Depois", + "label.all": "Todos", + "label.all-time": "Todos os períodos", + "label.analytics": "Análise", + "label.apply": "Aplicar", + "label.attribution": "Atribuição", + "label.attribution-description": "Veja como os usuários interagem com seu marketing e o que impulsiona conversões.", + "label.average": "Média", + "label.back": "Voltar", + "label.before": "Antes", + "label.behavior": "Comportamento", + "label.boards": "Quadros", + "label.bounce-rate": "Taxa de rejeição", + "label.breakdown": "Detalhamento", + "label.browser": "Navegador", + "label.browsers": "Navegadores", + "label.campaigns": "Campanhas", + "label.cancel": "Cancelar", + "label.change-password": "Alterar senha", + "label.channels": "Canais", + "label.cities": "Cidades", + "label.city": "Cidade", + "label.clear-all": "Limpar tudo", + "label.cohort": "Cohorte", + "label.compare": "Comparar", + "label.compare-dates": "Comparar datas", + "label.confirm": "Confirmar", + "label.confirm-password": "Confirmar senha", + "label.contains": "Contém", + "label.content": "Conteúdo", + "label.continue": "Continuar", + "label.conversion": "Conversão", + "label.conversion-rate": "Taxa de conversão", + "label.conversion-step": "Etapa de conversão", + "label.count": "Contagem", + "label.countries": "Países", + "label.country": "País", + "label.create": "Criar", + "label.create-report": "Criar relatório", + "label.create-team": "Criar equipe", + "label.create-user": "Criar usuário", + "label.created": "Criado", + "label.created-by": "Criado por", + "label.currency": "Moeda", + "label.current": "Atual", + "label.current-password": "Senha atual", + "label.custom-range": "Período personalizado", + "label.dashboard": "Painel", + "label.data": "Dados", + "label.date": "Data", + "label.date-range": "Período", + "label.day": "Dia", + "label.default-date-range": "Período padrão", + "label.delete": "Excluir", + "label.delete-report": "Excluir relatório", + "label.delete-team": "Excluir equipe", + "label.delete-user": "Excluir usuário", + "label.delete-website": "Excluir site", + "label.description": "Descrição", + "label.desktop": "Computador", + "label.details": "Detalhes", + "label.device": "Dispositivo", + "label.devices": "Dispositivos", + "label.direct": "Direto", + "label.dismiss": "Fechar", + "label.distinct-id": "ID distinto", + "label.does-not-contain": "Não contém", + "label.does-not-include": "Não inclui", + "label.doest-not-exist": "Não existe", + "label.domain": "Domínio", + "label.dropoff": "Abandono", + "label.edit": "Editar", + "label.edit-dashboard": "Editar painel", + "label.edit-member": "Editar membro", + "label.email": "Email", + "label.enable-share-url": "Ativar link para compartilhar", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "Evento", + "label.event-data": "Dados do evento", + "label.event-name": "Nome do evento", + "label.events": "Tipos de eventos", + "label.exists": "Existe", + "label.exit": "Exit URL", + "label.false": "Não", + "label.field": "Campo", + "label.fields": "Campos", + "label.filter": "Filtro", + "label.filter-combined": "Combinado", + "label.filter-raw": "Bruto", + "label.filters": "Filtros", + "label.first-click": "Primeiro clique", + "label.first-seen": "First seen", + "label.funnel": "Funil", + "label.funnel-description": "Entenda a taxa de conversão e abandono dos seus usuários.", + "label.funnels": "Funis", + "label.goal": "Meta", + "label.goals": "Metas", + "label.goals-description": "Acompanhe suas metas para visualizações de página e eventos.", + "label.greater-than": "Maior que", + "label.greater-than-equals": "Maior ou igual a", + "label.grouped": "Agrupado", + "label.hostname": "Nome do host", + "label.includes": "Inclui", + "label.insight": "Insight", + "label.insights": "Insights", + "label.insights-description": "Explore seus dados em mais detalhes usando filtros", + "label.is": "É igual a", + "label.is-false": "É falso", + "label.is-not": "Não é igual a", + "label.is-not-set": "Não definido", + "label.is-set": "Definido", + "label.is-true": "É verdadeiro", + "label.join": "Participar", + "label.join-team": "Participar da equipe", + "label.journey": "Jornada", + "label.journey-description": "Entenda como os usuários navegam pelo seu site.", + "label.journeys": "Jornadas", + "label.language": "Idioma", + "label.languages": "Idiomas", + "label.laptop": "Notebook", + "label.last-click": "Último clique", + "label.last-days": "Últimos {x} dias", + "label.last-hours": "Últimas {x} horas", + "label.last-months": "Últimos {x} meses", + "label.last-seen": "Última visualização", + "label.leave": "Sair", + "label.leave-team": "Sair da equipe", + "label.less-than": "Menor que", + "label.less-than-equals": "Menor ou igual a", + "label.links": "Links", + "label.login": "Entrar", + "label.logout": "Sair", + "label.manage": "Gerenciar", + "label.manager": "Manager", + "label.max": "Máximo", + "label.maximize": "Expandir", + "label.medium": "Médio", + "label.member": "Membro", + "label.members": "Membros", + "label.min": "Mínimo", + "label.mobile": "Celular", + "label.model": "Modelo", + "label.more": "Mais", + "label.my-account": "Minha conta", + "label.my-websites": "Meus sites", + "label.name": "Nome", + "label.new-password": "Nova senha", + "label.none": "Nenhum", + "label.number-of-records": "{x} {x, plural, one {registro} other {registros}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Busca orgânica", + "label.organic-shopping": "Compras orgânicas", + "label.organic-social": "Social orgânico", + "label.organic-video": "Vídeo orgânico", + "label.os": "Sistema operacional", + "label.other": "Outro", + "label.overview": "Visão geral", + "label.owner": "Proprietário", + "label.page": "Página", + "label.page-of": "Página {current} de {total}", + "label.page-views": "Visualizações de página", + "label.pageTitle": "Título", + "label.pages": "Páginas", + "label.paid-ads": "Anúncios pagos", + "label.paid-search": "Busca paga", + "label.paid-shopping": "Compras pagas", + "label.paid-social": "Social pago", + "label.paid-video": "Vídeo pago", + "label.password": "Senha", + "label.path": "Caminho", + "label.paths": "Caminhos", + "label.pixels": "Pixels", + "label.powered-by": "Desenvolvido por {name}", + "label.previous": "Anterior", + "label.previous-period": "Período anterior", + "label.previous-year": "Ano anterior", + "label.profile": "Perfil", + "label.properties": "Propriedades", + "label.property": "Propriedade", + "label.queries": "Consultas", + "label.query": "Consulta", + "label.query-parameters": "Parâmetros da consulta", + "label.realtime": "Tempo real", + "label.referral": "Referência", + "label.referrer": "Referência", + "label.referrers": "Referências", + "label.refresh": "Atualizar", + "label.regenerate": "Gerar novamente", + "label.region": "Estado", + "label.regions": "Estados", + "label.remaining": "Restante", + "label.remove": "Remover", + "label.remove-member": "Remover membro", + "label.reports": "Relatórios", + "label.required": "Obrigatório", + "label.reset": "Redefinir", + "label.reset-website": "Redefinir dados", + "label.retention": "Retenção", + "label.retention-description": "Avalie a fidelidade dos seus usuários medindo a frequência com que eles retornam.", + "label.revenue": "Receita", + "label.revenue-description": "Veja sua receita ao longo do tempo.", + "label.role": "Função", + "label.run-query": "Executar consulta", + "label.save": "Salvar", + "label.screens": "Tamanhos de tela", + "label.search": "Pesquisar", + "label.select": "Selecionar", + "label.select-date": "Selecionar data", + "label.select-filter": "Selecionar filtro", + "label.select-role": "Selecionar função", + "label.select-website": "Selecionar site", + "label.session": "Sessão", + "label.session-data": "Dados da sessão", + "label.sessions": "Sessões", + "label.settings": "Configurações", + "label.share": "Compartilhar", + "label.share-url": "Link para compartilhar", + "label.single-day": "Apenas um dia", + "label.sms": "SMS", + "label.sources": "Fontes", + "label.start-step": "Start Step", + "label.steps": "Etapas", + "label.sum": "Soma", + "label.tablet": "Tablet", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Equipe", + "label.team-id": "ID da equipe", + "label.team-manager": "Gerente da equipe", + "label.team-member": "Membro da equipe", + "label.team-name": "Nome da equipe", + "label.team-owner": "Proprietário da equipe", + "label.team-settings": "Configurações da equipe", + "label.team-view-only": "Apenas visualização da equipe", + "label.team-websites": "Sites da equipe", + "label.teams": "Equipes", + "label.terms": "Termos", + "label.theme": "Tema", + "label.this-month": "Este mês", + "label.this-week": "Esta semana", + "label.this-year": "Este ano", + "label.timezone": "Fuso horário", + "label.title": "Título", + "label.today": "Hoje", + "label.toggle-charts": "Alternar gráficos", + "label.total": "Total", + "label.total-records": "Total de registros", + "label.tracking-code": "Código de rastreamento", + "label.transactions": "Transactions", + "label.transfer": "Transferir", + "label.transfer-website": "Transferir site", + "label.true": "Sim", + "label.type": "Tipo", + "label.unique": "Únicos", + "label.unique-visitors": "Visitantes únicos", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Desconhecido", + "label.untitled": "Sem título", + "label.update": "Atualizar", + "label.user": "Usuário", + "label.username": "Nome de usuário", + "label.users": "Usuários", + "label.utm": "UTM", + "label.utm-description": "Acompanhe suas campanhas de publicidade através de parâmetros UTM.", + "label.value": "Valor", + "label.view": "Visualizar", + "label.view-details": "Ver mais", + "label.view-only": "Somente visualização", + "label.views": "Visualizações", + "label.views-per-visit": "Visualizações por visita", + "label.visit-duration": "Tempo médio de visita", + "label.visitors": "Visitantes", + "label.visits": "Visitas", + "label.website": "Site", + "label.website-id": "ID do site", + "label.websites": "Sites", + "label.window": "Janela", + "label.yesterday": "Ontem", + "message.action-confirmation": "Digite {confirmation} na caixa abaixo para confirmar.", + "message.active-users": " Atualmente {x} usuários ativos", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Tem certeza de que deseja excluir {target}?", + "message.confirm-leave": "Tem certeza de que deseja sair de {target}?", + "message.confirm-remove": "Tem certeza que deseja remover {target}?", + "message.confirm-reset": "Tem certeza que deseja redefinir os dados de {target}?", + "message.delete-team-warning": "Excluir a equipe também excluirá todos os sites da equipe.", + "message.delete-website-warning": "Todos os dados relacionados serão excluídos.", + "message.error": "Ocorreu um erro.", + "message.event-log": "{event} em {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Ir para as configurações", + "message.incorrect-username-password": "Nome de usuário ou senha incorretos.", + "message.invalid-domain": "Domínio inválido", + "message.min-password-length": "A senha deve ter no mínimo {n} caracteres", + "message.new-version-available": "Uma nova versão {version} do Umami está disponível!", + "message.no-data-available": "Não há dados disponíveis.", + "message.no-event-data": "Não há eventos disponíveis.", + "message.no-match-password": "As senhas não coincidem.", + "message.no-results-found": "Nenhum resultado encontrado.", + "message.no-team-websites": "Esta equipe não possui sites.", + "message.no-teams": "Você ainda não criou nenhuma equipe.", + "message.no-users": "Não há usuários.", + "message.no-websites-configured": "Você ainda não configurou nenhum site.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Página não encontrada.", + "message.reset-website": "Se você tiver certeza de que deseja redefinir este site, digite {confirmation} na caixa de entrada abaixo para confirmar.", + "message.reset-website-warning": "Todos os dados estatísticos deste site serão excluídos, mas seu código de rastreamento permanecerá o mesmo.", + "message.saved": "Salvo com sucesso.", + "message.sever-error": "Server error", + "message.share-url": "Este é o link para compartilhar {target}.", + "message.team-already-member": "Você já é membro desta equipe.", + "message.team-not-found": "Equipe não encontrada.", + "message.team-websites-info": "Qualquer membro da equipe pode visualizar os sites.", + "message.tracking-code": "Código de rastreamento", + "message.transfer-team-website-to-user": "Transferir este site para sua conta?", + "message.transfer-user-website-to-team": "Selecione para qual equipe deseja transferir este site.", + "message.transfer-website": "Transfira a propriedade do site para sua conta ou para outra equipe.", + "message.triggered-event": "Evento disparado", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Usuário excluído.", + "message.viewed-page": "Página visualizada", + "message.visitor-log": "Visitante de {country} usando o navegador {browser} em um {device} com sistema operacional {os}." +} diff --git a/src/lang/pt-PT.json b/src/lang/pt-PT.json new file mode 100644 index 0000000..86734cb --- /dev/null +++ b/src/lang/pt-PT.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Código de acesso", + "label.actions": "Ações", + "label.activity": "Registo de atividade", + "label.add": "Adicionar", + "label.add-board": "Adicionar quadro", + "label.add-description": "Adicionar descrição", + "label.add-member": "Adicionar membro", + "label.add-step": "Adicionar passo", + "label.add-website": "Adicionar website", + "label.admin": "Administrador", + "label.affiliate": "Afiliado", + "label.after": "Depois", + "label.all": "Todos", + "label.all-time": "Todo o tempo", + "label.analytics": "Análise", + "label.apply": "Aplicar", + "label.attribution": "Atribuição", + "label.attribution-description": "Veja como os utilizadores interagem com o seu marketing e o que impulsiona conversões.", + "label.average": "Média", + "label.back": "Voltar", + "label.before": "Antes", + "label.behavior": "Comportamento", + "label.boards": "Quadros", + "label.bounce-rate": "Taxa de rejeição", + "label.breakdown": "Detalhamento", + "label.browser": "Navegador", + "label.browsers": "Navegadores", + "label.campaigns": "Campanhas", + "label.cancel": "Cancelar", + "label.change-password": "Alterar senha", + "label.channels": "Canais", + "label.cities": "Cidades", + "label.city": "Cidade", + "label.clear-all": "Limpar tudo", + "label.cohort": "Cohorte", + "label.compare": "Comparar", + "label.compare-dates": "Comparar datas", + "label.confirm": "Confirmar", + "label.confirm-password": "Confirmar senha", + "label.contains": "Contains", + "label.content": "Conteúdo", + "label.continue": "Continue", + "label.conversion": "Conversão", + "label.conversion-rate": "Taxa de conversão", + "label.conversion-step": "Passo de conversão", + "label.count": "Contagem", + "label.countries": "Países", + "label.country": "País", + "label.create": "Criar", + "label.create-report": "Criar relatório", + "label.create-team": "Criar equipa", + "label.create-user": "Criar utilizador", + "label.created": "Criado", + "label.created-by": "Criado por", + "label.currency": "Moeda", + "label.current": "Atual", + "label.current-password": "Senha atual", + "label.custom-range": "Intervalo personalizado", + "label.dashboard": "Painel", + "label.data": "Data", + "label.date": "Date", + "label.date-range": "Intervalo de datas", + "label.day": "Dia", + "label.default-date-range": "Intervalo de datas predefinido", + "label.delete": "Eliminar", + "label.delete-report": "Eliminar relatório", + "label.delete-team": "Eliminar equipa", + "label.delete-user": "Eliminar utilizador", + "label.delete-website": "Eliminar website", + "label.description": "Descrição", + "label.desktop": "Computador", + "label.details": "Detalhes", + "label.device": "Dispositivo", + "label.devices": "Dispositivos", + "label.direct": "Direto", + "label.dismiss": "Ignorar", + "label.distinct-id": "ID distinto", + "label.does-not-contain": "Não contém", + "label.does-not-include": "Não inclui", + "label.doest-not-exist": "Não existe", + "label.domain": "Domínio", + "label.dropoff": "Dropoff", + "label.edit": "Editar", + "label.edit-dashboard": "Editar painel", + "label.edit-member": "Editar membro", + "label.email": "Email", + "label.enable-share-url": "Ativar link de partilha", + "label.end-step": "Passo final", + "label.entry": "URL de entrada", + "label.event": "Evento", + "label.event-data": "Dados do evento", + "label.event-name": "Nome do evento", + "label.events": "Eventos", + "label.exists": "Existe", + "label.exit": "URL de saída", + "label.false": "Falso", + "label.field": "Campo", + "label.fields": "Campos", + "label.filter": "Filtro", + "label.filter-combined": "Combinado", + "label.filter-raw": "Dados brutos", + "label.filters": "Filtros", + "label.first-click": "Primeiro clique", + "label.first-seen": "Primeira visualização", + "label.funnel": "Funil", + "label.funnel-description": "Compreenda a taxa de conversão e abandono dos utilizadores.", + "label.funnels": "Funis", + "label.goal": "Objetivo", + "label.goals": "Objetivos", + "label.goals-description": "Acompanhe os seus objetivos para visualizações de página e eventos.", + "label.greater-than": "Maior que", + "label.greater-than-equals": "Maior ou igual a", + "label.grouped": "Agrupado", + "label.hostname": "Nome do host", + "label.includes": "Inclui", + "label.insight": "Insight", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "É", + "label.is-false": "É falso", + "label.is-not": "Não é", + "label.is-not-set": "Não definido", + "label.is-set": "Definido", + "label.is-true": "É verdadeiro", + "label.join": "Juntar-se", + "label.join-team": "Juntar-se à equipa", + "label.journey": "Jornada", + "label.journey-description": "Compreenda como os utilizadores navegam no seu website.", + "label.journeys": "Jornadas", + "label.language": "Língua", + "label.languages": "Línguas", + "label.laptop": "Portátil", + "label.last-click": "Último clique", + "label.last-days": "Últimos {x} dias", + "label.last-hours": "Últimas {x} horas", + "label.last-months": "Últimos {x} meses", + "label.last-seen": "Última visualização", + "label.leave": "Sair", + "label.leave-team": "Sair da equipa", + "label.less-than": "Menor que", + "label.less-than-equals": "Menor ou igual a", + "label.links": "Ligações", + "label.login": "Iniciar sessão", + "label.logout": "Sair", + "label.manage": "Gerir", + "label.manager": "Gestor", + "label.max": "Máximo", + "label.maximize": "Expandir", + "label.medium": "Médio", + "label.member": "Membro", + "label.members": "Membros", + "label.min": "Mínimo", + "label.mobile": "Telemóvel", + "label.model": "Modelo", + "label.more": "Mais", + "label.my-account": "A minha conta", + "label.my-websites": "Os meus websites", + "label.name": "Nome", + "label.new-password": "Nova senha", + "label.none": "Nenhum", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Pesquisa orgânica", + "label.organic-shopping": "Compras orgânicas", + "label.organic-social": "Social orgânico", + "label.organic-video": "Vídeo orgânico", + "label.os": "OS", + "label.other": "Outro", + "label.overview": "Overview", + "label.owner": "Proprietário", + "label.page": "Página", + "label.page-of": "Page {current} of {total}", + "label.page-views": "Visualizações da página", + "label.pageTitle": "Page title", + "label.pages": "Páginas", + "label.paid-ads": "Anúncios pagos", + "label.paid-search": "Pesquisa paga", + "label.paid-shopping": "Compras pagas", + "label.paid-social": "Social pago", + "label.paid-video": "Vídeo pago", + "label.password": "Senha", + "label.path": "Caminho", + "label.paths": "Caminhos", + "label.pixels": "Píxeis", + "label.powered-by": "Distribuído por {name}", + "label.previous": "Anterior", + "label.previous-period": "Período anterior", + "label.previous-year": "Ano anterior", + "label.profile": "Perfil", + "label.properties": "Propriedades", + "label.property": "Propriedade", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "Tempo real", + "label.referral": "Referência", + "label.referrer": "Referrer", + "label.referrers": "Referenciadores", + "label.refresh": "Atualizar", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Restante", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "Obrigatório", + "label.reset": "Repor", + "label.reset-website": "Repor estatísticas", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Receita", + "label.revenue-description": "Veja a sua receita ao longo do tempo.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Guardar", + "label.screens": "Screens", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Selecionar filtro", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Sessão", + "label.session-data": "Dados da sessão", + "label.sessions": "Sessions", + "label.settings": "Definições", + "label.share": "Partilhar", + "label.share-url": "Partilhar link", + "label.single-day": "Dia único", + "label.sms": "SMS", + "label.sources": "Fontes", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "Tablet", + "label.tag": "Etiqueta", + "label.tags": "Etiquetas", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Gestor de equipa", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Definições da equipa", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Termos", + "label.theme": "Tema", + "label.this-month": "Este mês", + "label.this-week": "Esta semana", + "label.this-year": "Este ano", + "label.timezone": "Fuso horário", + "label.title": "Title", + "label.today": "Hoje", + "label.toggle-charts": "Alternar gráficos", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Código de rastreamento", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Visitantes únicos", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Desconhecido", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Nome de utilizador", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Ver detalhes", + "label.view-only": "View only", + "label.views": "Visualizações", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Tempo médio de visita", + "label.visitors": "Visitantes", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Websites", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} {x, plural, one {visitante} other {visitantes}} neste momento", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Tem a certeza que pretende eliminar {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Tem a certeza que pretende restaurar as estatísticas de {target}?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "Todos os dados associados também serão eliminados.", + "message.error": "Ocorreu um erro.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Ir para as definições", + "message.incorrect-username-password": "Nome de utilizador/senha incorretos.", + "message.invalid-domain": "Domínio inválido", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Sem dados disponíveis.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "As senhas não coincidem", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "Não tens nenhum website configurado.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Página não encontrada.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "Todas as estatísticas deste site serão eliminadas, mas o seu código de rastreamento permanecerá intacto.", + "message.saved": "Guardado com sucesso.", + "message.sever-error": "Server error", + "message.share-url": "Este é o link de partilha público para {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Código de rastreamento", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Visitante de {country} a usar {browser} no {device} {os}" +} diff --git a/src/lang/ro-RO.json b/src/lang/ro-RO.json new file mode 100644 index 0000000..7863330 --- /dev/null +++ b/src/lang/ro-RO.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Cod de access", + "label.actions": "Acțiuni", + "label.activity": "Jurnal de activități", + "label.add": "Adaugă", + "label.add-board": "Adaugă panou", + "label.add-description": "Adaugă descriere", + "label.add-member": "Adaugă membru", + "label.add-step": "Adaugă pas", + "label.add-website": "Adaugă site web", + "label.admin": "Administrator", + "label.affiliate": "Afiliat", + "label.after": "După", + "label.all": "Toate", + "label.all-time": "Pentru tot timpul", + "label.analytics": "Analiză", + "label.apply": "Aplică", + "label.attribution": "Atribuire", + "label.attribution-description": "Vezi cum utilizatorii interacționează cu marketingul tău și ce determină conversiile.", + "label.average": "Mediu", + "label.back": "Înapoi", + "label.before": "Înainte", + "label.behavior": "Comportament", + "label.boards": "Panouri", + "label.bounce-rate": "Rata de respingere", + "label.breakdown": "Detaliat", + "label.browser": "Browser", + "label.browsers": "Browsere", + "label.campaigns": "Campanii", + "label.cancel": "Anulează", + "label.change-password": "Schimbare parolă", + "label.channels": "Canale", + "label.cities": "Orașe", + "label.city": "Oraș", + "label.clear-all": "Șterge tot", + "label.cohort": "Cohortă", + "label.compare": "Compară", + "label.compare-dates": "Compară datele", + "label.confirm": "Confirm", + "label.confirm-password": "Confirmare parolă", + "label.contains": "Conține", + "label.content": "Conținut", + "label.continue": "Continuă", + "label.conversion": "Conversie", + "label.conversion-rate": "Rată de conversie", + "label.conversion-step": "Pas de conversie", + "label.count": "Număr", + "label.countries": "Țări", + "label.country": "Țară", + "label.create": "Crează", + "label.create-report": "Crează report", + "label.create-team": "Crează echipă", + "label.create-user": "Crează utilizator", + "label.created": "Creat", + "label.created-by": "Creat de", + "label.currency": "Monedă", + "label.current": "Curent", + "label.current-password": "Parola curentă", + "label.custom-range": "Interval personalizat", + "label.dashboard": "Tablou de bord", + "label.data": "Date", + "label.date": "Dată", + "label.date-range": "Interval", + "label.day": "Zi", + "label.default-date-range": "Interval implicit", + "label.delete": "Șterge", + "label.delete-report": "Șterge raport", + "label.delete-team": "Șterge echipă", + "label.delete-user": "Șterge utilizator", + "label.delete-website": "Șterge site web", + "label.description": "Descriere", + "label.desktop": "Desktop", + "label.details": "Detalii", + "label.device": "Dispozitiv", + "label.devices": "Dispozitive", + "label.direct": "Direct", + "label.dismiss": "Renunță", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "Nu conține", + "label.does-not-include": "Nu include", + "label.doest-not-exist": "Nu există", + "label.domain": "Domeniu", + "label.dropoff": "Rată de abandon", + "label.edit": "Editare", + "label.edit-dashboard": "Editare tablou de bord", + "label.edit-member": "Editare membru", + "label.email": "Email", + "label.enable-share-url": "Activare adresă URL de distribuire", + "label.end-step": "Pas final", + "label.entry": "URL de intrare", + "label.event": "Eveniment", + "label.event-data": "Date despre eveniment", + "label.event-name": "Nume eveniment", + "label.events": "Evenimente", + "label.exists": "Există", + "label.exit": "URL de ieșire", + "label.false": "Fals", + "label.field": "Câmp", + "label.fields": "Câmpuri", + "label.filter": "Filtru", + "label.filter-combined": "Combinat", + "label.filter-raw": "Brut", + "label.filters": "Filtre", + "label.first-click": "Primul click", + "label.first-seen": "Văzut pentru prima dată", + "label.funnel": "Parcursul utilizatorului", + "label.funnel-description": "Înțelege rata de conversie și rata de abandon a utilizatorilor.", + "label.funnels": "Parcursuri", + "label.goal": "Obiectiv", + "label.goals": "Obiective", + "label.goals-description": "Urmărește obiectivele de vizualizări și evenimente.", + "label.greater-than": "Mai mare decât", + "label.greater-than-equals": "Mai mare sau egal cu", + "label.grouped": "Grupat", + "label.hostname": "Nume gazdă", + "label.includes": "Include", + "label.insight": "Perspectivă", + "label.insights": "Perspective", + "label.insights-description": "Aprofundează datele utilizând segmente și filtre.", + "label.is": "Este", + "label.is-false": "Este fals", + "label.is-not": "Nu este", + "label.is-not-set": "Nu este setat", + "label.is-set": "Este setat", + "label.is-true": "Este adevărat", + "label.join": "Alătură-te", + "label.join-team": "Alătură-te echipei", + "label.journey": "Traseu", + "label.journey-description": "Înțelege cum navighează vizitatorii prin website.", + "label.journeys": "Trasee", + "label.language": "Limbă", + "label.languages": "Limbi", + "label.laptop": "Laptop", + "label.last-click": "Ultimul click", + "label.last-days": "Ultimele {x} zile", + "label.last-hours": "Ultimele {x} ore", + "label.last-months": "Ultimele {x} luni", + "label.last-seen": "Văzut ultima dată", + "label.leave": "Părăsește", + "label.leave-team": "Părăsește echipa", + "label.less-than": "Mai puțin decât", + "label.less-than-equals": "Mai puțin sau egal cu", + "label.links": "Linkuri", + "label.login": "Autentificare", + "label.logout": "Ieșire din cont", + "label.manage": "Administrează", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Extinde", + "label.medium": "Mediu", + "label.member": "Membru", + "label.members": "Membri", + "label.min": "Min", + "label.mobile": "Mobil", + "label.model": "Model", + "label.more": "Mai mult", + "label.my-account": "Contul meu", + "label.my-websites": "Website-ul meu", + "label.name": "Nume", + "label.new-password": "Parolă nouă", + "label.none": "Niciunul", + "label.number-of-records": "{x} {x, plural, one {înregistrare} other {înregistrări}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Căutare organică", + "label.organic-shopping": "Cumpărături organice", + "label.organic-social": "Social organic", + "label.organic-video": "Video organic", + "label.os": "OS", + "label.other": "Altul", + "label.overview": "Vedere de ansamblu", + "label.owner": "Titular", + "label.page": "Pagină", + "label.page-of": "Pagina {current} din {total}", + "label.page-views": "Vizualizări de pagină", + "label.pageTitle": "Titlul paginii", + "label.pages": "Pagini", + "label.paid-ads": "Reclame plătite", + "label.paid-search": "Căutare plătită", + "label.paid-shopping": "Cumpărături plătite", + "label.paid-social": "Social plătit", + "label.paid-video": "Video plătit", + "label.password": "Parolă", + "label.path": "Rută", + "label.paths": "Rute", + "label.pixels": "Pixeli", + "label.powered-by": "Cu sprijinul {name}", + "label.previous": "Anterior", + "label.previous-period": "Perioda anterioară", + "label.previous-year": "Anul anterior", + "label.profile": "Profil", + "label.properties": "Proprietăți", + "label.property": "Proprietate", + "label.queries": "Interogări", + "label.query": "Interogare", + "label.query-parameters": "Parametri de interogare", + "label.realtime": "Timp real", + "label.referral": "Referral", + "label.referrer": "Proveniență", + "label.referrers": "Site-uri de proveniență", + "label.refresh": "Reîmprospătare", + "label.regenerate": "Regenerează", + "label.region": "Regiune", + "label.regions": "Regiuni", + "label.remaining": "Rămas", + "label.remove": "Îndepărtează", + "label.remove-member": "Îndepărtează membru", + "label.reports": "Rapoarte", + "label.required": "Obligatoriu", + "label.reset": "Resetează", + "label.reset-website": "Resetează statisticile pentru site", + "label.retention": "Retenție", + "label.retention-description": "Măsoară atractivitatea site-ului tău prin urmărirea frecvenței cu care utilizatorii se întorc.", + "label.revenue": "Venit", + "label.revenue-description": "Urmărește venitul în timp.", + "label.role": "Rol", + "label.run-query": "Execută interogarea", + "label.save": "Salvează", + "label.screens": "Ecrane", + "label.search": "Căutare", + "label.select": "Selectează", + "label.select-date": "Selectează data", + "label.select-filter": "Selectează filtru", + "label.select-role": "Selectează rolul", + "label.select-website": "Selectează website", + "label.session": "Sesiune", + "label.session-data": "Date sesiune", + "label.sessions": "Sesiuni", + "label.settings": "Setări", + "label.share": "Partajează", + "label.share-url": "Partajare URL", + "label.single-day": "O singură zi", + "label.sms": "SMS", + "label.sources": "Surse", + "label.start-step": "Pas de început", + "label.steps": "Pași", + "label.sum": "Sumă", + "label.tablet": "Tabletă", + "label.tag": "Etichetă", + "label.tags": "Etichete", + "label.team": "Echipă", + "label.team-id": "ID Echipă", + "label.team-manager": "Manager echipă", + "label.team-member": "Membru echipă", + "label.team-name": "Nume echipă", + "label.team-owner": "Titular echipă", + "label.team-settings": "Setări echipă", + "label.team-view-only": "Doar vizualizare echipă", + "label.team-websites": "Website-uri echipă", + "label.teams": "Echipă", + "label.terms": "Termeni", + "label.theme": "Temă", + "label.this-month": "Această lună", + "label.this-week": "Această săptămână", + "label.this-year": "Acest an", + "label.timezone": "Fus orar", + "label.title": "Titlu", + "label.today": "Astăzi", + "label.toggle-charts": "Schimbă graficele", + "label.total": "Total", + "label.total-records": "Total înregistrări", + "label.tracking-code": "Cod de urmărire", + "label.transactions": "Tranzacții", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "Adevărat", + "label.type": "Tip", + "label.unique": "Unici", + "label.unique-visitors": "Vizitatori unici", + "label.uniqueCustomers": "Clienți unici", + "label.unknown": "Necunoscut", + "label.untitled": "Fără titlu", + "label.update": "Update", + "label.user": "Utilizator", + "label.username": "Nume utilizator", + "label.users": "Utilizatori", + "label.utm": "UTM", + "label.utm-description": "Urmărește campaniile tale cu parametri UTM.", + "label.value": "Valoare", + "label.view": "Vizualizare", + "label.view-details": "Vizualizare detalii", + "label.view-only": "Doar vizualizare", + "label.views": "Vizualizări", + "label.views-per-visit": "Vizualizări per vizită", + "label.visit-duration": "Timp mediu de vizitare", + "label.visitors": "Vizitatori", + "label.visits": "Vizite", + "label.website": "Website", + "label.website-id": "ID Website", + "label.websites": "Site-uri web", + "label.window": "Fereastră", + "label.yesterday": "Ieri", + "message.action-confirmation": "Scrie {confirmation} în câmpul de mai jos pentru a confirma.", + "message.active-users": "{x} {x, plural, one {vizitator activ} other {vizitatori activi}}", + "message.bad-request": "Bad request", + "message.collected-data": "Date colectate", + "message.confirm-delete": "Ești sigur că vrei să ștergi {target}?", + "message.confirm-leave": "Ești sigur că vrei să părăsești {target}?", + "message.confirm-remove": "Ești sigur că vrei să ștergi {target}?", + "message.confirm-reset": "Ești sigur că vrei să resetezi statisticile pentru {target}?", + "message.delete-team-warning": "Ștergerea unei echipe va șterge și toate website-urile echipei.", + "message.delete-website-warning": "Toate datele asociate vor fi șterse, de asemenea.", + "message.error": "Ceva n-a mers bine.", + "message.event-log": "{event} la {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Mergi la Setări", + "message.incorrect-username-password": "Nume utilizator / parolă incorecte.", + "message.invalid-domain": "Domeniul nu este valid", + "message.min-password-length": "Lungimea minimă este de {n} caractere", + "message.new-version-available": "O nouă versiune de Umami {version} este disponibilă!", + "message.no-data-available": "Nicio informație disponibilă.", + "message.no-event-data": "Nu sunt disponibile date legate de eveniment.", + "message.no-match-password": "Parolele nu se potrivesc", + "message.no-results-found": "Nu a fost găsit niciun rezultat.", + "message.no-team-websites": "Echipa aceasta nu are niciun website.", + "message.no-teams": "Nu ai creat nicio echipă.", + "message.no-users": "Nu există utilizatori.", + "message.no-websites-configured": "Nu ai niciun site web configurat.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Pagina nu a fost găsită.", + "message.reset-website": "Pentru a reseta acest website, scrie {confirmation} În câmpul de mai jos pentru a confirma.", + "message.reset-website-warning": "Toate statisticile pentru acest site web vor fi șterse, dar codul de urmărire va rămâne intact.", + "message.saved": "Salvat cu succes.", + "message.sever-error": "Server error", + "message.share-url": "Aceasta este adresa URL de partajare pentru {target}.", + "message.team-already-member": "Deja ești membru al acestei echipe.", + "message.team-not-found": "Echipa nu a fost găsită.", + "message.team-websites-info": "Site-urile web pot fi vizualizate de către oricare membru al echipei.", + "message.tracking-code": "Cod de urmărire", + "message.transfer-team-website-to-user": "Vrei să transferi acest website pe contul tău?", + "message.transfer-user-website-to-team": "Selectează echipa căreia vrei să îi transferi site-ul.", + "message.transfer-website": "Transferă titulatura site-ului către tine sau către o altă echipă.", + "message.triggered-event": "Eveniment declanșat", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Utilizator șters.", + "message.viewed-page": "Pagină vizualizată", + "message.visitor-log": "Vizitator din {country} folosind {browser} pe {os} {device}" +} diff --git a/src/lang/ru-RU.json b/src/lang/ru-RU.json new file mode 100644 index 0000000..96d0538 --- /dev/null +++ b/src/lang/ru-RU.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Код доступа", + "label.actions": "Действия", + "label.activity": "Журнал активности", + "label.add": "Добавить", + "label.add-board": "Добавить доску", + "label.add-description": "Добавить описание", + "label.add-member": "Добавить участника", + "label.add-step": "Добавить шаг", + "label.add-website": "Добавить сайт", + "label.admin": "Администратор", + "label.affiliate": "Партнер", + "label.after": "После", + "label.all": "Все", + "label.all-time": "Все время", + "label.analytics": "Аналитика", + "label.apply": "Применить", + "label.attribution": "Атрибуция", + "label.attribution-description": "Посмотрите, как пользователи взаимодействуют с вашим маркетингом и что приводит к конверсиям.", + "label.average": "Средний", + "label.back": "Назад", + "label.before": "До", + "label.behavior": "Поведение", + "label.boards": "Доски", + "label.bounce-rate": "Отказы", + "label.breakdown": "Авария", + "label.browser": "Браузер", + "label.browsers": "Браузеры", + "label.campaigns": "Кампании", + "label.cancel": "Отменить", + "label.change-password": "Изменить пароль", + "label.channels": "Каналы", + "label.cities": "Города", + "label.city": "Город", + "label.clear-all": "Очистить все", + "label.cohort": "Когорта", + "label.compare": "Сравнить", + "label.compare-dates": "Сравнить даты", + "label.confirm": "Подтвердить", + "label.confirm-password": "Подтвердить пароль", + "label.contains": "Содержит", + "label.content": "Контент", + "label.continue": "Продолжить", + "label.conversion": "Конверсия", + "label.conversion-rate": "Коэффициент конверсии", + "label.conversion-step": "Шаг конверсии", + "label.count": "Считать", + "label.countries": "Страны", + "label.country": "Страна", + "label.create": "Создать", + "label.create-report": "Создать отчет", + "label.create-team": "Создать команду", + "label.create-user": "Создать пользователя", + "label.created": "Создано", + "label.created-by": "Создано", + "label.currency": "Валюта", + "label.current": "Текущий", + "label.current-password": "Текущий пароль", + "label.custom-range": "Другой период", + "label.dashboard": "Информационная панель", + "label.data": "Данные", + "label.date": "Дата", + "label.date-range": "Диапазон дат", + "label.day": "День", + "label.default-date-range": "Диапазон дат по-умолчанию", + "label.delete": "Удалить", + "label.delete-report": "Удалить отчет", + "label.delete-team": "Удалить команду", + "label.delete-user": "Удалить пользователя", + "label.delete-website": "Удалить сайт", + "label.description": "Описание", + "label.desktop": "Настольный компьютер", + "label.details": "Подробности", + "label.device": "Устройство", + "label.devices": "Устройства", + "label.direct": "Direct", + "label.dismiss": "Отклонить", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "Не содержит", + "label.does-not-include": "Не включает", + "label.doest-not-exist": "Не существует", + "label.domain": "Домен", + "label.dropoff": "Высадка", + "label.edit": "Изменить", + "label.edit-dashboard": "Редактировать дашборд", + "label.edit-member": "Редактировать участника", + "label.email": "Email", + "label.enable-share-url": "Разрешить делиться ссылкой", + "label.end-step": "Конечный шаг", + "label.entry": "URL-адрес входа", + "label.event": "Событие", + "label.event-data": "Данные о событии", + "label.event-name": "Название события", + "label.events": "События", + "label.exists": "Существует", + "label.exit": "URL-адрес выхода", + "label.false": "Ложь", + "label.field": "Поле", + "label.fields": "Поля", + "label.filter": "Фильтр", + "label.filter-combined": "Объединенные", + "label.filter-raw": "Сырые данные", + "label.filters": "Фильтры", + "label.first-click": "Первый клик", + "label.first-seen": "Первый вход", + "label.funnel": "Воронка", + "label.funnel-description": "Изучите коэффициент конверсии и ухода пользователей.", + "label.funnels": "Воронки", + "label.goal": "Цель", + "label.goals": "Цели", + "label.goals-description": "Отслеживайте свои цели по просмотрам страниц и событиям.", + "label.greater-than": "Больше, чем", + "label.greater-than-equals": "Больше или равно", + "label.grouped": "Группировано", + "label.hostname": "Имя хоста", + "label.includes": "Включает", + "label.insight": "Инсайт", + "label.insights": "Информация", + "label.insights-description": "Погрузитесь глубже в свои данные с помощью сегментов и фильтров.", + "label.is": "Является", + "label.is-false": "Ложно", + "label.is-not": "Не установлен", + "label.is-not-set": "Не установлено", + "label.is-set": "Установлен", + "label.is-true": "Истинно", + "label.join": "Присоединиться", + "label.join-team": "Присоединиться к команде", + "label.journey": "Journey", + "label.journey-description": "Поймите, как пользователи перемещаются по вашему сайту.", + "label.journeys": "Пути", + "label.language": "Язык", + "label.languages": "Языки", + "label.laptop": "Ноутбук", + "label.last-click": "Последний клик", + "label.last-days": "Последние {x} дней", + "label.last-hours": "Последние {x} часа", + "label.last-months": "Последние {x} месяцев", + "label.last-seen": "Последний вход", + "label.leave": "Уйти", + "label.leave-team": "Покинуть команду", + "label.less-than": "Меньше, чем", + "label.less-than-equals": "Меньше или равно", + "label.links": "Ссылки", + "label.login": "Войти", + "label.logout": "Выйти", + "label.manage": "Управление", + "label.manager": "Менеджер", + "label.max": "Максимум", + "label.maximize": "Развернуть", + "label.medium": "Средний", + "label.member": "Участник", + "label.members": "Участники", + "label.min": "Минимум", + "label.mobile": "Смартфон", + "label.model": "Модель", + "label.more": "Больше", + "label.my-account": "Мой профиль", + "label.my-websites": "Мои сайты", + "label.name": "Имя", + "label.new-password": "Новый пароль", + "label.none": "Не указано", + "label.number-of-records": "{x} {x, plural, one {запись} other {записи}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Органический поиск", + "label.organic-shopping": "Органические покупки", + "label.organic-social": "Органические соцсети", + "label.organic-video": "Органическое видео", + "label.os": "OS", + "label.other": "Другое", + "label.overview": "Обзор", + "label.owner": "Владелец", + "label.page": "Страница", + "label.page-of": "Страница {current} из {total}", + "label.page-views": "Просмотры страниц", + "label.pageTitle": "Название страницы", + "label.pages": "Страницы", + "label.paid-ads": "Платная реклама", + "label.paid-search": "Платный поиск", + "label.paid-shopping": "Платные покупки", + "label.paid-social": "Платные соцсети", + "label.paid-video": "Платное видео", + "label.password": "Пароль", + "label.path": "Путь", + "label.paths": "Пути", + "label.pixels": "Пиксели", + "label.powered-by": "На движке {name}", + "label.previous": "Предыдущий", + "label.previous-period": "Предыдущий период", + "label.previous-year": "Предыдущий год", + "label.profile": "Профиль", + "label.properties": "Свойства", + "label.property": "Свойство", + "label.queries": "Запросы", + "label.query": "Запрос", + "label.query-parameters": "Параметры запроса", + "label.realtime": "Реальное время", + "label.referral": "Referral", + "label.referrer": "Реферер", + "label.referrers": "Источники", + "label.refresh": "Обновить", + "label.regenerate": "Обновить", + "label.region": "Регион", + "label.regions": "Регионы", + "label.remaining": "Осталось", + "label.remove": "Удалить", + "label.remove-member": "Удалить участника", + "label.reports": "Отчеты", + "label.required": "Обязательное", + "label.reset": "Сбросить", + "label.reset-website": "Сбросить статистику", + "label.retention": "Удержание", + "label.retention-description": "Измерьте «прилипаемость» вашего сайта, отслеживая, как часто пользователи возвращаются на него.", + "label.revenue": "Выручка", + "label.revenue-description": "Изучите свои доходы за определенное время.", + "label.role": "Роль", + "label.run-query": "Выполнить запрос", + "label.save": "Сохранить", + "label.screens": "Экраны", + "label.search": "Поиск", + "label.select": "Выберите", + "label.select-date": "Выберите дату", + "label.select-filter": "Выберите фильтр", + "label.select-role": "Выберите роль", + "label.select-website": "Выбрать сайт", + "label.session": "Сессия", + "label.session-data": "Данные сессии", + "label.sessions": "Сессии", + "label.settings": "Настройки", + "label.share": "Поделиться", + "label.share-url": "Поделиться ссылкой", + "label.single-day": "Один день", + "label.sms": "SMS", + "label.sources": "Источники", + "label.start-step": "Начальный этап", + "label.steps": "Шаги", + "label.sum": "Сумма", + "label.tablet": "Планшет", + "label.tag": "Тег", + "label.tags": "Теги", + "label.team": "Команда", + "label.team-id": "ID команды", + "label.team-manager": "Менеджер команды", + "label.team-member": "Член команды", + "label.team-name": "Название команды", + "label.team-owner": "Владелец команды", + "label.team-settings": "Настройки команды", + "label.team-view-only": "Только командный просмотр", + "label.team-websites": "Веб-сайты команды", + "label.teams": "Команды", + "label.terms": "Условия", + "label.theme": "Тема", + "label.this-month": "Этот месяц", + "label.this-week": "Эта неделя", + "label.this-year": "Этот год", + "label.timezone": "Часовой пояс", + "label.title": "Заголовок", + "label.today": "Сегодня", + "label.toggle-charts": "Показать/скрыть графики", + "label.total": "Всего", + "label.total-records": "Всего записей", + "label.tracking-code": "Код отслеживания", + "label.transactions": "Транзакции", + "label.transfer": "Передача", + "label.transfer-website": "Передать сайт", + "label.true": "Правда", + "label.type": "Тип", + "label.unique": "Уникальный", + "label.unique-visitors": "Уникальные посетители", + "label.uniqueCustomers": "Уникальные клиенты", + "label.unknown": "Неизвестно", + "label.untitled": "Без названия", + "label.update": "Обновление", + "label.user": "Пользователь", + "label.username": "Имя пользователя", + "label.users": "Пользователи", + "label.utm": "UTM", + "label.utm-description": "Отслеживайте свои кампании с помощью UTM-параметров.", + "label.value": "Значение", + "label.view": "Просмотреть", + "label.view-details": "Посмотреть детали", + "label.view-only": "Только просмотр", + "label.views": "Просмотры", + "label.views-per-visit": "Просмотров за посещение", + "label.visit-duration": "Среднее время посещения", + "label.visitors": "Посетители", + "label.visits": "Посещения", + "label.website": "Сайт", + "label.website-id": "ID сайта", + "label.websites": "Сайты", + "label.window": "Окно", + "label.yesterday": "Вчера", + "message.action-confirmation": "Введите {confirmation} в поле ниже, чтобы подтвердить.", + "message.active-users": "{x} текущих посетителей", + "message.bad-request": "Bad request", + "message.collected-data": "Собранные данные", + "message.confirm-delete": "Вы уверены, что хотите удалить {target}?", + "message.confirm-leave": "Вы уверены, что хотите уйти {target}?", + "message.confirm-remove": "Вы уверены, что хотите удалить {target}?", + "message.confirm-reset": "Вы уверены, что хотите сбросить статистику {target}?", + "message.delete-team-warning": "При удалении команды будут удалены и все ее веб-сайты.", + "message.delete-website-warning": "Все связанные данные будут также удалены.", + "message.error": "Что-то пошло не так.", + "message.event-log": "{event} на {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Перейти к настройкам", + "message.incorrect-username-password": "Неверное имя пользователя/пароль.", + "message.invalid-domain": "Некорректный домен", + "message.min-password-length": "Минимальная длина {n} символов", + "message.new-version-available": "Вышла новая версия Umami {version}!", + "message.no-data-available": "Нет данных.", + "message.no-event-data": "Данные о событиях отсутствуют.", + "message.no-match-password": "Пароли не совпадают", + "message.no-results-found": "Результаты не найдены.", + "message.no-team-websites": "У этой команды нет ни одного сайта.", + "message.no-teams": "Вы не создали ни одной команды.", + "message.no-users": "Нет пользователей.", + "message.no-websites-configured": "У вас нет настроенных сайтов.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Страница не найдена.", + "message.reset-website": "Для сброса введите RESET", + "message.reset-website-warning": "Вся статистика для этого сайта будет удалена, но ваш код отслеживания останется нетронутым.", + "message.saved": "Успешно сохранено.", + "message.sever-error": "Server error", + "message.share-url": "Это публичная ссылка для {target}.", + "message.team-already-member": "Вы уже состоите в команде.", + "message.team-not-found": "Команда не найдена.", + "message.team-websites-info": "Сайты могут просматривать все члены команды.", + "message.tracking-code": "Код отслеживания", + "message.transfer-team-website-to-user": "Перенести этот сайт в свой прфоиль?", + "message.transfer-user-website-to-team": "Выберите команду, которой нужно передать этот сайт.", + "message.transfer-website": "Передайте право владения сайтом своей учетной записи или другой команде.", + "message.triggered-event": "Запущенное событие", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Пользователь удален.", + "message.viewed-page": "Просмотренная страница", + "message.visitor-log": "Посетитель из {country} используя {browser} на {os} {device}" +} diff --git a/src/lang/si-LK.json b/src/lang/si-LK.json new file mode 100644 index 0000000..3e6aff8 --- /dev/null +++ b/src/lang/si-LK.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Access code", + "label.actions": "Actions", + "label.activity": "Activity log", + "label.add": "Add", + "label.add-board": "Add board", + "label.add-description": "Add description", + "label.add-member": "Add member", + "label.add-step": "Add step", + "label.add-website": "වෙබ් අඩවිය එක් කරන්න", + "label.admin": "Administrator", + "label.affiliate": "Affiliate", + "label.after": "After", + "label.all": "සියල්ල", + "label.all-time": "හැම වෙලාවෙම", + "label.analytics": "Analytics", + "label.apply": "Apply", + "label.attribution": "Attribution", + "label.attribution-description": "See how users engage with your marketing and what drives conversions.", + "label.average": "Average", + "label.back": "ආපසු", + "label.before": "Before", + "label.behavior": "අචරණය", + "label.boards": "Boards", + "label.bounce-rate": "Bounce rate", + "label.breakdown": "Breakdown", + "label.browser": "Browser", + "label.browsers": "Browsers", + "label.campaigns": "Campaigns", + "label.cancel": "අවලංගු කරන්න", + "label.change-password": "මුරපදය වෙනස් කරන්න", + "label.channels": "Channels", + "label.cities": "Cities", + "label.city": "City", + "label.clear-all": "Clear all", + "label.cohort": "Cohort", + "label.compare": "Compare", + "label.compare-dates": "Compare dates", + "label.confirm": "Confirm", + "label.confirm-password": "මුරපදය සත්යාපනය කරන්න", + "label.contains": "Contains", + "label.content": "Content", + "label.continue": "Continue", + "label.conversion": "Conversion", + "label.conversion-rate": "Conversion rate", + "label.conversion-step": "Conversion step", + "label.count": "Count", + "label.countries": "Countries", + "label.country": "Country", + "label.create": "Create", + "label.create-report": "Create report", + "label.create-team": "Create team", + "label.create-user": "Create user", + "label.created": "Created", + "label.created-by": "Created By", + "label.currency": "Currency", + "label.current": "Current", + "label.current-password": "වත්මන් මුරපදය", + "label.custom-range": "අභිරුචි පරාසය", + "label.dashboard": "උපකරණ පුවරුව", + "label.data": "Data", + "label.date": "Date", + "label.date-range": "දින පරාසය", + "label.day": "Day", + "label.default-date-range": "පෙරනිමි දින පරාසය", + "label.delete": "මකන්න", + "label.delete-report": "Delete report", + "label.delete-team": "Delete team", + "label.delete-user": "Delete user", + "label.delete-website": "වෙබ් අඩවිය මකන්න", + "label.description": "Description", + "label.desktop": "Desktop", + "label.details": "Details", + "label.device": "Device", + "label.devices": "Devices", + "label.direct": "Direct", + "label.dismiss": "මගහරින්න", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "Does not contain", + "label.does-not-include": "Does not include", + "label.doest-not-exist": "Does not exist", + "label.domain": "වසම", + "label.dropoff": "Dropoff", + "label.edit": "සංස්කරණය කරන්න", + "label.edit-dashboard": "Edit dashboard", + "label.edit-member": "Edit member", + "label.email": "Email", + "label.enable-share-url": "බෙදාගැනීමේ URL සබල කරන්න", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "Event", + "label.event-data": "සිදුවීම් දත්ත", + "label.event-name": "Event name", + "label.events": "Events", + "label.exists": "Exists", + "label.exit": "Exit URL", + "label.false": "False", + "label.field": "Field", + "label.fields": "Fields", + "label.filter": "Filter", + "label.filter-combined": "Combined", + "label.filter-raw": "Raw", + "label.filters": "Filters", + "label.first-click": "First click", + "label.first-seen": "First seen", + "label.funnel": "Funnel", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Funnels", + "label.goal": "Goal", + "label.goals": "Goals", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "Greater than", + "label.greater-than-equals": "Greater than or equals", + "label.grouped": "Grouped", + "label.hostname": "Hostname", + "label.includes": "Includes", + "label.insight": "Insight", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "Is", + "label.is-false": "Is false", + "label.is-not": "Is not", + "label.is-not-set": "Is not set", + "label.is-set": "Is set", + "label.is-true": "Is true", + "label.join": "Join", + "label.join-team": "Join team", + "label.journey": "Journey", + "label.journey-description": "Understand how users navigate through your website.", + "label.journeys": "Journeys", + "label.language": "භාෂාව", + "label.languages": "Languages", + "label.laptop": "Laptop", + "label.last-click": "Last click", + "label.last-days": "අන්තිම {x} දින", + "label.last-hours": "අන්තිම {x} පැය", + "label.last-months": "Last {x} months", + "label.last-seen": "Last seen", + "label.leave": "Leave", + "label.leave-team": "Leave team", + "label.less-than": "Less than", + "label.less-than-equals": "Less than or equals", + "label.links": "Links", + "label.login": "ලොග් වෙන්න", + "label.logout": "පිටවීම", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Expand", + "label.medium": "Medium", + "label.member": "Member", + "label.members": "Members", + "label.min": "Min", + "label.mobile": "Mobile", + "label.model": "Model", + "label.more": "තවත්", + "label.my-account": "My account", + "label.my-websites": "My websites", + "label.name": "නම", + "label.new-password": "අලුත් මුරපදය", + "label.none": "කිසිවක් නැත", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organic search", + "label.organic-shopping": "Organic shopping", + "label.organic-social": "Organic social", + "label.organic-video": "Organic video", + "label.os": "OS", + "label.other": "Other", + "label.overview": "Overview", + "label.owner": "හිමිකරු", + "label.page": "Page", + "label.page-of": "Page {current} of {total}", + "label.page-views": "Page views", + "label.pageTitle": "Page title", + "label.pages": "Pages", + "label.paid-ads": "Paid ads", + "label.paid-search": "Paid search", + "label.paid-shopping": "Paid shopping", + "label.paid-social": "Paid social", + "label.paid-video": "Paid video", + "label.password": "මුරපදය", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "Pixels", + "label.powered-by": "Powered by {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "පැතිකඩ", + "label.properties": "Properties", + "label.property": "Property", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "තත්ය කාල", + "label.referral": "Referral", + "label.referrer": "Referrer", + "label.referrers": "Referrers", + "label.refresh": "නැවුම් කරන්න", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Remaining", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "අවශ්යයි", + "label.reset": "යළි පිහිටුවන්න", + "label.reset-website": "සංඛ්යා ලේඛන නැවත සකසන්න", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "සුරකින්න", + "label.screens": "Screens", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Select filter", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Session", + "label.session-data": "Session data", + "label.sessions": "Sessions", + "label.settings": "සැකසුම්", + "label.share": "Share", + "label.share-url": "බෙදාගැනීමේ URL", + "label.single-day": "තනි දවස", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "Tablet", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Team settings", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Terms", + "label.theme": "තේමාව", + "label.this-month": "මෙ මාසය", + "label.this-week": "මේ සතිය", + "label.this-year": "මේ අවුරුද්ද", + "label.timezone": "වේලා කලාපය", + "label.title": "Title", + "label.today": "අද", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "ලුහුබැඳීමේ කේතය", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Unique visitors", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "නොදනී", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "පරිශීලක නාමය", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "තොරතුරු පෙන්වන්න", + "label.view-only": "View only", + "label.views": "Views", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Visit duration", + "label.visitors": "Visitors", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "වෙබ් අඩවි", + "label.window": "Window", + "label.yesterday": "ඊයේ", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} දැන් {x, plural, one {අමුත්තා} other {අමුත්තන්}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "{target} මකා දැමීම ගැන විශ්වාසද?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "{target} ට අදාල සංඛ්යාලේඛන නැවත පිහිටුවීමට අවශ්යද?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "All website data will be deleted.", + "message.error": "Something went wrong.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "සැකසීම් වෙත යන්න", + "message.incorrect-username-password": "වැරදි පරිශීලක නාමය/මුරපදය.", + "message.invalid-domain": "Invalid domain. Do not include http/https.", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "පෙන්වීමට දත්ත නොමැත.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Passwords do not match.", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "You do not have any websites configured.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "පිටුව හමු නොවීය.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your settings will remain intact.", + "message.saved": "Saved.", + "message.sever-error": "Server error", + "message.share-url": "මේ {target} සඳහා ප්රසිද්ධියේ බෙදාගත් URL එකයි.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "To track stats for this website, place the following code in the <head>...</head> section of your HTML.", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Visitor from {country} using {browser} on {os} {device}" +} diff --git a/src/lang/sk-SK.json b/src/lang/sk-SK.json new file mode 100644 index 0000000..297d5e3 --- /dev/null +++ b/src/lang/sk-SK.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Prístupový kód", + "label.actions": "Akcie", + "label.activity": "Denník aktivít", + "label.add": "Pridať", + "label.add-board": "Pridať tabuľu", + "label.add-description": "Pridať popis", + "label.add-member": "Pridať člena", + "label.add-step": "Pridať krok", + "label.add-website": "Pridať web", + "label.admin": "Administrátor", + "label.affiliate": "Partner", + "label.after": "Po", + "label.all": "Všetko", + "label.all-time": "Celý čas", + "label.analytics": "Analytika", + "label.apply": "Použiť", + "label.attribution": "Priradenie", + "label.attribution-description": "Pozrite sa, ako používatelia interagujú s vaším marketingom a čo vedie ku konverziám.", + "label.average": "Priemer", + "label.back": "Späť", + "label.before": "Pred", + "label.behavior": "Správanie", + "label.boards": "Tabule", + "label.bounce-rate": "Okamžité opustenie", + "label.breakdown": "Rozpis", + "label.browser": "Prehliadač", + "label.browsers": "Prehliadač", + "label.campaigns": "Kampane", + "label.cancel": "Zrušiť", + "label.change-password": "Zmeniť heslo", + "label.channels": "Kanály", + "label.cities": "Mestá", + "label.city": "Mesto", + "label.clear-all": "Vymazať všetko", + "label.cohort": "Kohorta", + "label.compare": "Porovnať", + "label.compare-dates": "Porovnať dátumy", + "label.confirm": "Potvrdiť", + "label.confirm-password": "Potvrdiť heslo", + "label.contains": "Contains", + "label.content": "Obsah", + "label.continue": "Continue", + "label.conversion": "Konverzia", + "label.conversion-rate": "Miera konverzie", + "label.conversion-step": "Krok konverzie", + "label.count": "Počet", + "label.countries": "Zem", + "label.country": "Krajina", + "label.create": "Vytvoriť", + "label.create-report": "Vytvoriť správu", + "label.create-team": "Vytvoriť tím", + "label.create-user": "Vytvoriť používateľa", + "label.created": "Vytvorené", + "label.created-by": "Vytvoril", + "label.currency": "Mena", + "label.current": "Aktuálny", + "label.current-password": "Aktuálne heslo", + "label.custom-range": "Vlastný rozsah", + "label.dashboard": "Prehlad", + "label.data": "Data", + "label.date": "Date", + "label.date-range": "Obdobie", + "label.day": "Deň", + "label.default-date-range": "Predvolené obdobie", + "label.delete": "Zmazať", + "label.delete-report": "Zmazať správu", + "label.delete-team": "Zmazať tím", + "label.delete-user": "Zmazať používateľa", + "label.delete-website": "Zmazať web", + "label.description": "Popis", + "label.desktop": "Stolný počítač", + "label.details": "Details", + "label.device": "Zariadenie", + "label.devices": "Zariadenie", + "label.direct": "Priamy", + "label.dismiss": "Odísť", + "label.distinct-id": "Jedinečné ID", + "label.does-not-contain": "Neobsahuje", + "label.does-not-include": "Nezahŕňa", + "label.doest-not-exist": "Neexistuje", + "label.domain": "Doména", + "label.dropoff": "Dropoff", + "label.edit": "Upraviť", + "label.edit-dashboard": "Upraviť prehľad", + "label.edit-member": "Upraviť člena", + "label.email": "Email", + "label.enable-share-url": "Povoliť zdielanie URL", + "label.end-step": "Konečný krok", + "label.entry": "Vstupná URL", + "label.event": "Udalosť", + "label.event-data": "Dáta udalosti", + "label.event-name": "Názov udalosti", + "label.events": "Udalosti", + "label.exists": "Existuje", + "label.exit": "Výstupná URL", + "label.false": "Nepravda", + "label.field": "Pole", + "label.fields": "Polia", + "label.filter": "Filter", + "label.filter-combined": "Kombinácie", + "label.filter-raw": "Nezpracované", + "label.filters": "Filtre", + "label.first-click": "Prvé kliknutie", + "label.first-seen": "Prvýkrát videné", + "label.funnel": "Lievik", + "label.funnel-description": "Pochopte mieru konverzie a odchodu používateľov.", + "label.funnels": "Lieviky", + "label.goal": "Cieľ", + "label.goals": "Ciele", + "label.goals-description": "Sledujte svoje ciele pre zobrazenia stránok a udalosti.", + "label.greater-than": "Väčšie ako", + "label.greater-than-equals": "Väčšie alebo rovné", + "label.grouped": "Zoskupené", + "label.hostname": "Názov hostiteľa", + "label.includes": "Zahŕňa", + "label.insight": "Prehľad", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "Je", + "label.is-false": "Je nepravda", + "label.is-not": "Nie je", + "label.is-not-set": "Nie je nastavené", + "label.is-set": "Nastavené", + "label.is-true": "Je pravda", + "label.join": "Pripojiť sa", + "label.join-team": "Pripojiť sa k tímu", + "label.journey": "Cesta", + "label.journey-description": "Pochopte, ako používatelia prechádzajú vaším webom.", + "label.journeys": "Cesty", + "label.language": "Jazyk", + "label.languages": "Jazyky", + "label.laptop": "Prenosný počítač", + "label.last-click": "Posledné kliknutie", + "label.last-days": "Posledných {x} dní", + "label.last-hours": "Posledných {x} hodín", + "label.last-months": "Posledných {x} mesiacov", + "label.last-seen": "Naposledy videné", + "label.leave": "Odísť", + "label.leave-team": "Opustiť tím", + "label.less-than": "Menej ako", + "label.less-than-equals": "Menej alebo rovné", + "label.links": "Odkazy", + "label.login": "Prihlásiť", + "label.logout": "Odhlásiť", + "label.manage": "Spravovať", + "label.manager": "Manažér", + "label.max": "Maximum", + "label.maximize": "Rozbaliť", + "label.medium": "Stredný", + "label.member": "Člen", + "label.members": "Členovia", + "label.min": "Minimum", + "label.mobile": "Mobilný telefon", + "label.model": "Model", + "label.more": "Viac", + "label.my-account": "Môj účet", + "label.my-websites": "Moje weby", + "label.name": "Meno", + "label.new-password": "Nové heslo", + "label.none": "Žiadny", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organické vyhľadávanie", + "label.organic-shopping": "Organické nakupovanie", + "label.organic-social": "Organické sociálne siete", + "label.organic-video": "Organické video", + "label.os": "OS", + "label.other": "Iné", + "label.overview": "Overview", + "label.owner": "Owner", + "label.page": "Stránka", + "label.page-of": "Page {current} of {total}", + "label.page-views": "Zobrazenie stánok", + "label.pageTitle": "Page title", + "label.pages": "Stránky", + "label.paid-ads": "Platené reklamy", + "label.paid-search": "Platené vyhľadávanie", + "label.paid-shopping": "Platené nakupovanie", + "label.paid-social": "Platené sociálne siete", + "label.paid-video": "Platené video", + "label.password": "Heslo", + "label.path": "Cesta", + "label.paths": "Cesty", + "label.pixels": "Pixely", + "label.powered-by": "Powered by {name}", + "label.previous": "Predchádzajúci", + "label.previous-period": "Predchádzajúce obdobie", + "label.previous-year": "Predchádzajúci rok", + "label.profile": "Profil", + "label.properties": "Vlastnosti", + "label.property": "Vlastnosť", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "Aktuálne", + "label.referral": "Odporúčanie", + "label.referrer": "Referrer", + "label.referrers": "Odkazy", + "label.refresh": "Obnoviť", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Zostáva", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "Povinné", + "label.reset": "Reset", + "label.reset-website": "Reset statistics", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Príjem", + "label.revenue-description": "Pozrite si svoj príjem v priebehu času.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "Uložiť", + "label.screens": "Screens", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Vybrať filter", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Sedenie", + "label.session-data": "Dáta sedenia", + "label.sessions": "Sessions", + "label.settings": "Nastavenia", + "label.share": "Zdieľať", + "label.share-url": "Zdielanie URL", + "label.single-day": "Jeden deň", + "label.sms": "SMS", + "label.sources": "Zdroje", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "Tablet", + "label.tag": "Značka", + "label.tags": "Značky", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Manažér tímu", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Nastavenia tímu", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Podmienky", + "label.theme": "Theme", + "label.this-month": "Tento mesiac", + "label.this-week": "Tento týždeň", + "label.this-year": "Tento rok", + "label.timezone": "Časová zóna", + "label.title": "Title", + "label.today": "Dnes", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "Sledovací kód", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "Jedinečné návštevy", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Neznámý", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "Užívateľské meno", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "Zobraziť detaily", + "label.view-only": "View only", + "label.views": "Zobrazení", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Priemerný čas návštevy", + "label.visitors": "Návštevy", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "Weby", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} aktuálne {x, plural, one {návštevník} other {návštěvníci}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Naozaj zmazať {target}?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "Všetky príbuzné data budu tiež zmazané.", + "message.error": "Niečo sa pokazilo.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Ísť do nastavení", + "message.incorrect-username-password": "Nesprávné meno/heslo.", + "message.invalid-domain": "Neplatná doména", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "Žiadne data.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "Hesla se nezhodujú", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "Nemáte nastavený žiadny web.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Stránka sa nenašla.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.", + "message.saved": "Úspešne uložené.", + "message.sever-error": "Server error", + "message.share-url": "Toto je zdielané URL pre {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "Sledovací kód", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Návštevník z {country} s prehliadačom {browser} na {os} {device}" +} diff --git a/src/lang/sl-SI.json b/src/lang/sl-SI.json new file mode 100644 index 0000000..3dd3226 --- /dev/null +++ b/src/lang/sl-SI.json @@ -0,0 +1,318 @@ +{ + "label.access-code": "Koda za dostop", + "label.actions": "Dejanja", + "label.activity": "Dnevnik dejavnosti", + "label.add": "Dodaj", + "label.add-board": "Dodaj tablo", + "label.add-description": "Dodaj opis", + "label.add-member": "Dodaj člana", + "label.add-step": "Dodaj korak", + "label.add-website": "Dodaj spletno mesto", + "label.admin": "Administrator", + "label.affiliate": "Partner", + "label.after": "Po", + "label.all": "Vsi", + "label.all-time": "Ves čas", + "label.analytics": "Analitika", + "label.apply": "Uporabi", + "label.attribution": "Pripis", + "label.attribution-description": "Oglejte si, kako uporabniki sodelujejo z vašim marketingom in kaj spodbuja konverzije.", + "label.average": "Povprečno", + "label.back": "Nazaj", + "label.before": "Pred", + "label.behavior": "Obnašanje", + "label.boards": "Table", + "label.bounce-rate": "Odbojna stopnja", + "label.breakdown": "Razčlenitev", + "label.browser": "Brskalnik", + "label.browsers": "Brskalniki", + "label.campaigns": "Kampanje", + "label.cancel": "Prekliči", + "label.change-password": "Zamenjaj geslo", + "label.channels": "Kanali", + "label.cities": "Mesta", + "label.city": "Mesto", + "label.clear-all": "Počisti vse", + "label.compare": "Primerjaj", + "label.confirm": "Potrdi", + "label.confirm-password": "Potrdi geslo", + "label.contains": "Vsebuje", + "label.content": "Vsebina", + "label.continue": "Nadaljuj", + "label.count": "Število", + "label.countries": "Države", + "label.country": "Država", + "label.create": "Ustvari", + "label.create-report": "Ustvari poročilo", + "label.create-team": "Ustvari ekipo", + "label.create-user": "Ustvari uporabnika", + "label.created": "Ustvarjeno", + "label.created-by": "Ustvaril", + "label.current": "Trenutno", + "label.current-password": "Trenutno geslo", + "label.custom-range": "Obdobje po meri", + "label.dashboard": "Nadzorna plošča", + "label.data": "Podatki", + "label.date": "Datum", + "label.date-range": "Časovno obdobje", + "label.day": "Dan", + "label.default-date-range": "Privzeto časovno obdobje", + "label.delete": "Izbriši", + "label.delete-report": "Izbriši poročilo", + "label.delete-team": "Izbriši ekipo", + "label.delete-user": "Izbriši uporabnika", + "label.delete-website": "Izbriši spletno mesto", + "label.description": "Opis", + "label.desktop": "Namizni računalnik", + "label.details": "Podrobnosti", + "label.device": "Naprava", + "label.devices": "Naprave", + "label.direct": "Neposredno", + "label.dismiss": "Prezri", + "label.distinct-id": "Unikatni ID", + "label.does-not-contain": "Ne vsebuje", + "label.does-not-include": "Ne vključuje", + "label.doest-not-exist": "Ne obstaja", + "label.domain": "Domena", + "label.dropoff": "Zapustitev", + "label.edit": "Uredi", + "label.edit-dashboard": "Uredi nadzorno ploščo", + "label.edit-member": "Uredi člana", + "label.enable-share-url": "Omogoči povezavo za deljenje", + "label.end-step": "Končni korak", + "label.entry": "Vstopni URL", + "label.event": "Dogodek", + "label.event-data": "Podatki dogodka", + "label.event-name": "Ime dogodka", + "label.events": "Dogodki", + "label.exit": "Izhodni URL", + "label.false": "Napačno", + "label.field": "Polje", + "label.fields": "Polja", + "label.filter": "Filter", + "label.filter-combined": "Skupaj", + "label.filter-raw": "Neobdelano", + "label.filters": "Filtri", + "label.first-seen": "Prvič viden", + "label.funnel": "Prodajni lijak", + "label.funnel-description": "Razumite stopnjo konverzije in osipa uporabnikov.", + "label.goal": "Cilj", + "label.goals": "Cilji", + "label.goals-description": "Spremljajte svoje cilje za oglede strani in dogodke.", + "label.greater-than": "Večje od", + "label.greater-than-equals": "Večje ali enako kot", + "label.host": "Gostitelj", + "label.hosts": "Gostitelji", + "label.insights": "Vpogled", + "label.insights-description": "Poglobite se v podatke z uporabo segmentov in filtrov.", + "label.is": "Je", + "label.is-false": "Je napačno", + "label.is-not": "Ni", + "label.is-not-set": "Ni nastavljeno", + "label.is-set": "Je nastavljeno", + "label.is-true": "Je res", + "label.join": "Pridruži se", + "label.join-team": "Pridruži se ekipi", + "label.journey": "Uporabniška pot", + "label.journey-description": "Razumite, kako uporabniki krmarijo po vašem spletnem mestu.", + "label.language": "Jezik", + "label.languages": "Jeziki", + "label.laptop": "Prenosni računalnik", + "label.last-click": "Zadnji klik", + "label.last-days": "Zadnjih {x} dni", + "label.last-hours": "Zadnjih {x} ur", + "label.last-months": "Zadnjih {x} mesecev", + "label.last-seen": "Nazadnje viden", + "label.leave": "Zapusti", + "label.leave-team": "Zapusti ekipo", + "label.less-than": "Manjše kot", + "label.less-than-equals": "Manjše ali enako kot", + "label.links": "Povezave", + "label.login": "Prijava", + "label.logout": "Odjava", + "label.manage": "Upravljaj", + "label.manager": "Upravitelj", + "label.max": "Največ", + "label.member": "Član", + "label.members": "Člani", + "label.min": "Najmanj", + "label.mobile": "Mobilne naprave", + "label.model": "Model", + "label.more": "Več", + "label.my-account": "Moj račun", + "label.my-websites": "Moja spletna mesta", + "label.name": "Ime", + "label.new-password": "Novo geslo", + "label.none": "Noben", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organsko iskanje", + "label.organic-shopping": "Organski nakupi", + "label.organic-social": "Organska družbena omrežja", + "label.organic-video": "Organski video", + "label.os": "OS", + "label.other": "Drugo", + "label.overview": "Pregled", + "label.owner": "Lastnik", + "label.page": "Stran", + "label.page-of": "Stran {current} od {total}", + "label.page-views": "Obiski strani", + "label.pageTitle": "Naslov strani", + "label.pages": "Strani", + "label.paid-ads": "Plačani oglasi", + "label.paid-search": "Plačano iskanje", + "label.paid-shopping": "Plačani nakupi", + "label.paid-social": "Plačana družbena omrežja", + "label.paid-video": "Plačani video", + "label.password": "Geslo", + "label.path": "Pot", + "label.paths": "Poti", + "label.powered-by": "Poganja {name}", + "label.previous": "Prejšnji", + "label.previous-period": "Prejšnje obdobje", + "label.previous-year": "Prejšnje leto", + "label.profile": "Profil", + "label.properties": "Lastnosti", + "label.property": "Lastnost", + "label.queries": "Poizvedbe", + "label.query": "Poizvedba", + "label.query-parameters": "Parametri poizvedbe", + "label.realtime": "V živo", + "label.referral": "Napoten", + "label.referrer": "Vir", + "label.referrers": "Viri", + "label.refresh": "Osveži", + "label.regenerate": "Ponovno generiraj", + "label.region": "Regija", + "label.regions": "Regije", + "label.remaining": "Preostalo", + "label.remove": "Odstrani", + "label.remove-member": "Odstrani člana", + "label.reports": "Poročila", + "label.required": "Zahtevano", + "label.reset": "Ponastavi", + "label.reset-website": "Ponastavi statistiko", + "label.retention": "Ohranjanje uporabnikov", + "label.retention-description": "Merite uporabnikovo zadržanost s sledenjem, kako pogosto se vračajo.", + "label.revenue": "Prihodki", + "label.revenue-description": "Preglejte svoje prihodke skozi čas.", + "label.revenue-property": "Lastnost prihodkov", + "label.role": "Vloga", + "label.run-query": "Izvedi poizvedbo", + "label.save": "Shrani", + "label.screens": "Zasloni", + "label.search": "Išči", + "label.select": "Izberi", + "label.select-date": "Izberi datum", + "label.select-role": "Izberi vlogo", + "label.select-website": "Izberi spletno mesto", + "label.session": "Seja", + "label.sessions": "Seje", + "label.settings": "Nastavitve", + "label.share": "Deli", + "label.share-url": "Deli povezavo", + "label.single-day": "En dan", + "label.start-step": "Začetni korak", + "label.steps": "Koraki", + "label.sum": "Seštevek", + "label.tablet": "Tablični računalnik", + "label.tag": "Oznaka", + "label.tags": "Oznake", + "label.team": "Ekipa", + "label.team-id": "ID ekipe", + "label.team-manager": "Upravitelj ekipe", + "label.team-member": "Član ekipe", + "label.team-name": "Ime ekipe", + "label.team-owner": "Lastnik ekipe", + "label.team-view-only": "Ekipa samo za ogled", + "label.team-websites": "Spletna mesta ekipe", + "label.teams": "Ekipe", + "label.terms": "Pogoji", + "label.theme": "Tema", + "label.this-month": "Ta mesec", + "label.this-week": "Ta teden", + "label.this-year": "To leto", + "label.timezone": "Časovni pas", + "label.title": "Naslov", + "label.today": "Danes", + "label.toggle-charts": "Preklopi grafe", + "label.total": "Skupaj", + "label.total-records": "Skupni zapisi", + "label.tracking-code": "Koda za sledenje", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "Pravilno", + "label.type": "Vrsta", + "label.unique": "Unikatni", + "label.unique-visitors": "Unikatni obiskovalci", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Neznano", + "label.untitled": "Brez naslova", + "label.update": "Update", + "label.user": "Uporabnik", + "label.username": "Uporabniško ime", + "label.users": "Uporabniki", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Vrednost", + "label.view": "Poglej", + "label.view-details": "Poglej podrobnosti", + "label.view-only": "Samo ogledovanje", + "label.views": "Obiski", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Povprečni čas obiska", + "label.visitors": "Obiskovalci", + "label.visits": "Visits", + "label.website": "Spletno mesto", + "label.website-id": "ID spletnega mesta", + "label.websites": "Spletna mesta", + "label.window": "Okno", + "label.yesterday": "Včeraj", + "message.action-confirmation": "Za potrditev v spodnje polje vnesite {confirmation}.", + "message.active-users": "{x} trenutni {x, plural, one {obiskovalec} other {obiskovalcev}}", + "message.collected-data": "Zbrani podatki", + "message.confirm-delete": "Ste prepričani, da želite izbrisati {target}?", + "message.confirm-leave": "Ste prepričani, da želite zapustiti {target}?", + "message.confirm-remove": "Ali ste prepričani, da želite odstraniti {target}?", + "message.confirm-reset": "Ste prepričani, da želite ponastaviti statistiko {target}?", + "message.delete-team-warning": "Brisanje ekipe bo izbrisalo tudi vsa spletna mesta ekipe.", + "message.delete-website-warning": "Izbrisani bodo tudi vsi pripadajoči podatki.", + "message.error": "Nekaj je šlo narobe.", + "message.event-log": "{event} na {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Pojdi v nastavitve", + "message.incorrect-username-password": "Nepravilno uporabniško ime/geslo.", + "message.invalid-domain": "Neveljavna domena", + "message.min-password-length": "Najmanjša dolžina je {n} znakov", + "message.new-version-available": "Na voljo je nova verzija programa Umami {version}!", + "message.no-data-available": "Podatki niso na voljo.", + "message.no-event-data": "Podatki o dogodku niso na voljo.", + "message.no-match-password": "Gesli se ne ujemata", + "message.no-results-found": "Rezultatov ni bilo mogoče najti.", + "message.no-team-websites": "Ta ekipa nima spletnih mest.", + "message.no-teams": "Niste še ustvarili nobene ekipe.", + "message.no-users": "Ni uporabnikov.", + "message.no-websites-configured": "Nimate nastavljenih nobenih spletnih mest.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Stran ni bila najdena.", + "message.reset-website": "Za ponastavitev izbrisa tega spletnega mesta vnesite {confirmation} v spodnje polje.", + "message.reset-website-warning": "Vse statistike za to spletno mesto bodo izbrisane, koda za sledenje pa bo ostala nespremenjena.", + "message.saved": "Uspešno shranjeno.", + "message.sever-error": "Server error", + "message.share-url": "To je javno dostopna povezava za {target}.", + "message.team-already-member": "Ste že član ekipe.", + "message.team-not-found": "Ekipa ni bila najdena.", + "message.team-websites-info": "Spletne strani si lahko ogleda vsak član ekipe.", + "message.tracking-code": "Koda za sledenje", + "message.transfer-team-website-to-user": "Želite prenesti to spletno mesto v svoj račun?", + "message.transfer-user-website-to-team": "Izberite ekipo, na katero želite prenesti to spletno mesto.", + "message.transfer-website": "Prenesite lastništvo spletnega mesta na svoj račun ali drugo ekipo.", + "message.triggered-event": "Sprožen dogodek", + "message.user-deleted": "Uporabnik je izbrisan.", + "message.viewed-page": "Ogledana stran", + "message.visitor-log": "Obiskovalec iz {country} uporablja {browser} na {os} {device}", + "message.visitors-dropped-off": "Osip obiskovalcev" +} diff --git a/src/lang/sv-SE.json b/src/lang/sv-SE.json new file mode 100644 index 0000000..1f456b0 --- /dev/null +++ b/src/lang/sv-SE.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Åtkomstkod", + "label.actions": "Händelser", + "label.activity": "Aktivitetslogg", + "label.add": "Lägg till", + "label.add-board": "Lägg till anslagstavla", + "label.add-description": "Lägg till beskrivning", + "label.add-member": "Lägg till medlem", + "label.add-step": "Lägg till steg", + "label.add-website": "Lägg till webbplats", + "label.admin": "Administratör", + "label.affiliate": "Partner", + "label.after": "Efter", + "label.all": "Alla", + "label.all-time": "Sedan början", + "label.analytics": "Webbplats Analys", + "label.apply": "Tillämpa", + "label.attribution": "Attribuering", + "label.attribution-description": "Se hur användare interagerar med din marknadsföring och vad som driver konverteringar.", + "label.average": "Genomsnitt", + "label.back": "Tillbaka", + "label.before": "Före", + "label.behavior": "Beteende", + "label.boards": "Anslagstavlor", + "label.bounce-rate": "Avvisningsfrekvens", + "label.breakdown": "Analys", + "label.browser": "Webbläsare", + "label.browsers": "Webbläsare", + "label.campaigns": "Kampanjer", + "label.cancel": "Avbryt", + "label.change-password": "Byt lösenord", + "label.channels": "Kanaler", + "label.cities": "Städer", + "label.city": "Stad", + "label.clear-all": "Rensa alla", + "label.cohort": "Kohort", + "label.compare": "Jämför", + "label.compare-dates": "Jämför datum", + "label.confirm": "Bekräfta", + "label.confirm-password": "Bekräfta lösenord", + "label.contains": "Innehåller", + "label.content": "Innehåll", + "label.continue": "Fortsätt", + "label.conversion": "Konvertering", + "label.conversion-rate": "Konverteringsfrekvens", + "label.conversion-step": "Konverteringssteg", + "label.count": "Antal", + "label.countries": "Länder", + "label.country": "Land", + "label.create": "Skapa", + "label.create-report": "Skapa rapport", + "label.create-team": "Skapa team", + "label.create-user": "Skapa användare", + "label.created": "Skapad", + "label.created-by": "Skapad av", + "label.currency": "Valuta", + "label.current": "Nuvarande", + "label.current-password": "Nuvarande lösenord", + "label.custom-range": "Anpassat urval", + "label.dashboard": "Översikt", + "label.data": "Data", + "label.date": "Datum", + "label.date-range": "Tidsperiod", + "label.day": "Dag", + "label.default-date-range": "Standard datum-urval", + "label.delete": "Radera", + "label.delete-report": "Radera rapport", + "label.delete-team": "Radera team", + "label.delete-user": "Radera användare", + "label.delete-website": "Radera webbplats", + "label.description": "Beskrivning", + "label.desktop": "Stationär", + "label.details": "Detaljer", + "label.device": "Enhet", + "label.devices": "Enheter", + "label.direct": "Direkt", + "label.dismiss": "Avbryt", + "label.distinct-id": "Unikt ID", + "label.does-not-contain": "Innehåller inte", + "label.does-not-include": "Inkluderar inte", + "label.doest-not-exist": "Existerar inte", + "label.domain": "Domän", + "label.dropoff": "Bortfall", + "label.edit": "Redigera", + "label.edit-dashboard": "Redigera översikt", + "label.edit-member": "Redigera medlem", + "label.email": "Email", + "label.enable-share-url": "Aktivera delningslänk", + "label.end-step": "Slutsteg", + "label.entry": "Ingångs-URL", + "label.event": "Händelse", + "label.event-data": "Händelsedata", + "label.event-name": "Händelsenamn", + "label.events": "Händelser", + "label.exists": "Existerar", + "label.exit": "Exit URL", + "label.false": "Falskt", + "label.field": "Fält", + "label.fields": "Fältar", + "label.filter": "Filter", + "label.filter-combined": "Kombinerade", + "label.filter-raw": "Rådata", + "label.filters": "Filter", + "label.first-click": "Första klicket", + "label.first-seen": "First seen", + "label.funnel": "Funnel", + "label.funnel-description": "Förstå omvandlingen och bortfallsfrekvensen för användare.", + "label.funnels": "Trattar", + "label.goal": "Mål", + "label.goals": "Mål", + "label.goals-description": "Följ dina mål för sidvisningar och händelser.", + "label.greater-than": "Större än", + "label.greater-than-equals": "Större än eller lika med", + "label.grouped": "Grupperad", + "label.hostname": "Värdnamn", + "label.includes": "Inkluderar", + "label.insight": "Insikt", + "label.insights": "Insikter", + "label.insights-description": "Dyk djupare in i din data genom att använda olika segment och filter.", + "label.is": "Är", + "label.is-false": "Är falskt", + "label.is-not": "Är inte", + "label.is-not-set": "Är inte inställd", + "label.is-set": "Är inställd", + "label.is-true": "Är sant", + "label.join": "Gå med", + "label.join-team": "Gå med i team", + "label.journey": "Resa", + "label.journey-description": "Förstå hur användare navigerar på din webbplats.", + "label.journeys": "Resor", + "label.language": "Språk", + "label.languages": "Språk", + "label.laptop": "Bärbar", + "label.last-click": "Sista klicket", + "label.last-days": "Senaste {x} dagarna", + "label.last-hours": "Senaste {x} timmarna", + "label.last-months": "Senaste {x} månaderna", + "label.last-seen": "Senast sedd", + "label.leave": "Lämna", + "label.leave-team": "Lämna team", + "label.less-than": "Mindre än", + "label.less-than-equals": "Mindre än eller lika med", + "label.links": "Länkar", + "label.login": "Logga in", + "label.logout": "Logga ut", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Expandera", + "label.medium": "Medium", + "label.member": "Medlem", + "label.members": "Medlemmar", + "label.min": "Min", + "label.mobile": "Mobil", + "label.model": "Modell", + "label.more": "Mer", + "label.my-account": "Mitt konto", + "label.my-websites": "Mina webbplatser", + "label.name": "Namn", + "label.new-password": "Nytt lösenord", + "label.none": "Ingen", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organisk sökning", + "label.organic-shopping": "Organisk shopping", + "label.organic-social": "Organisk social", + "label.organic-video": "Organisk video", + "label.os": "Operativsystem", + "label.other": "Annat", + "label.overview": "Översikt", + "label.owner": "Ägare", + "label.page": "Sida", + "label.page-of": "Sida {current} av {total}", + "label.page-views": "Sidvisningar", + "label.pageTitle": "Sidtitel", + "label.pages": "Sidor", + "label.paid-ads": "Betalda annonser", + "label.paid-search": "Betald sökning", + "label.paid-shopping": "Betald shopping", + "label.paid-social": "Betald social", + "label.paid-video": "Betald video", + "label.password": "Lösenord", + "label.path": "Sökväg", + "label.paths": "Sökvägar", + "label.pixels": "Pixlar", + "label.powered-by": "Drivs av {name}", + "label.previous": "Föregående", + "label.previous-period": "Föregående period", + "label.previous-year": "Föregående år", + "label.profile": "Profil", + "label.properties": "Egenskaper", + "label.property": "Egenskap", + "label.queries": "Frågor", + "label.query": "Fråga", + "label.query-parameters": "Frågeparametrar", + "label.realtime": "Realtid", + "label.referral": "Hänvisning", + "label.referrer": "Hänvisare", + "label.referrers": "Hänvisare", + "label.refresh": "Uppdatera", + "label.regenerate": "Förnya", + "label.region": "Region", + "label.regions": "Regioner", + "label.remaining": "Återstår", + "label.remove": "Ta bort", + "label.remove-member": "Remove member", + "label.reports": "Rapporter", + "label.required": "Krävs", + "label.reset": "Återställ", + "label.reset-website": "Återställ webbplats", + "label.retention": "Retention", + "label.retention-description": "Mät din webbplats engagemang genom att följa hur ofta användare återvänder.", + "label.revenue": "Intäkter", + "label.revenue-description": "Se dina intäkter över tid.", + "label.role": "Roll", + "label.run-query": "Kör sökning", + "label.save": "Spara", + "label.screens": "Upplösning", + "label.search": "Sök", + "label.select": "Select", + "label.select-date": "Välj datum", + "label.select-filter": "Välj filter", + "label.select-role": "Select role", + "label.select-website": "Välj webbplats", + "label.session": "Session", + "label.session-data": "Sessionsdata", + "label.sessions": "Sessioner", + "label.settings": "Inställningar", + "label.share": "Dela", + "label.share-url": "Delningslänk", + "label.single-day": "En dag", + "label.sms": "SMS", + "label.sources": "Källor", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Summa", + "label.tablet": "Surfplatta", + "label.tag": "Tagg", + "label.tags": "Taggar", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Teamledare", + "label.team-member": "Team-medlem", + "label.team-name": "Team namn", + "label.team-owner": "Team-ägare", + "label.team-settings": "Teaminställningar", + "label.team-view-only": "Team view only", + "label.team-websites": "Team webbplatser", + "label.teams": "Team", + "label.terms": "Villkor", + "label.theme": "Tema", + "label.this-month": "Denna månad", + "label.this-week": "Denna vecka", + "label.this-year": "Detta år", + "label.timezone": "Tidszon", + "label.title": "Titel", + "label.today": "Idag", + "label.toggle-charts": "Visa/göm grafer", + "label.total": "Totalt", + "label.total-records": "Totala poster", + "label.tracking-code": "Spårningskod", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "Sant", + "label.type": "Typ", + "label.unique": "Unikt", + "label.unique-visitors": "Unika besökare", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Okänt", + "label.untitled": "Namnlös", + "label.update": "Update", + "label.user": "Användare", + "label.username": "Användarnamn", + "label.users": "Användare", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Värde", + "label.view": "Visa", + "label.view-details": "Visa detaljer", + "label.view-only": "Endast visning", + "label.views": "Visningar", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "Genomsnittlig besökstid", + "label.visitors": "Besökare", + "label.visits": "Visits", + "label.website": "Webbplats", + "label.website-id": "Webbplats ID", + "label.websites": "Webbplatser", + "label.window": "Fönster", + "label.yesterday": "Igår", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} {x, plural, one {besökare} other {besökare}} just nu", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Är du säker på att du vill radera {target}?", + "message.confirm-leave": "Är du säker på att du vill lämna {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Är du säker på att du vill återställa statistiken för {target}?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "All tillhörande data kommer också att raderas.", + "message.error": "Något gick fel.", + "message.event-log": "{event} på {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Gå till inställningar", + "message.incorrect-username-password": "Felaktigt användarnamn/lösenord.", + "message.invalid-domain": "Ogiltig domän", + "message.min-password-length": "Minst {n} tecken", + "message.new-version-available": "En ny version av Umami {version} är tillgänglig!", + "message.no-data-available": "Ingen data tillgänglig.", + "message.no-event-data": "Ingen händelsedata är tillgänglig.", + "message.no-match-password": "Lösenorden matchar inte", + "message.no-results-found": "Inga resultat hittades.", + "message.no-team-websites": "Det här teamet har inga webbplatser.", + "message.no-teams": "Du har inte skapat några team.", + "message.no-users": "Det finns inga användare.", + "message.no-websites-configured": "Du har inte konfigurerat några webbplatser.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Sidan kunde inte hittas.", + "message.reset-website": "För att återställa webbplatsen, skriv {confirmation} i rutan nedan.", + "message.reset-website-warning": "All statistik för webbplatsen tas bort, men spårningskoden förblir oförändrad.", + "message.saved": "Sparat!", + "message.sever-error": "Server error", + "message.share-url": "Det här är den offentliga delningslänken för {target}.", + "message.team-already-member": "Du är redan medlem i teamet.", + "message.team-not-found": "Teamet kunde inte hittas.", + "message.team-websites-info": "Webbplatserna kan ses av alla i teamet.", + "message.tracking-code": "Spårningskod", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Användaren har raderats.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "Besökare från {country} med {browser} på {os} {device}" +} diff --git a/src/lang/ta-IN.json b/src/lang/ta-IN.json new file mode 100644 index 0000000..9e33d7b --- /dev/null +++ b/src/lang/ta-IN.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Access code", + "label.actions": "செயல்கள்", + "label.activity": "Activity log", + "label.add": "Add", + "label.add-board": "Add board", + "label.add-description": "Add description", + "label.add-member": "Add member", + "label.add-step": "Add step", + "label.add-website": "வலைத்தளத்தைச் சேர்க்க", + "label.admin": "நிர்வாகியைச் சேர்க்க", + "label.affiliate": "Affiliate", + "label.after": "After", + "label.all": "எல்லாம்", + "label.all-time": "All time", + "label.analytics": "Analytics", + "label.apply": "Apply", + "label.attribution": "Attribution", + "label.attribution-description": "See how users engage with your marketing and what drives conversions.", + "label.average": "Average", + "label.back": "பின்னால்", + "label.before": "Before", + "label.boards": "Boards", + "label.behavior": "நடத்தை", + "label.bounce-rate": "துள்ளல் விகிதம்", + "label.breakdown": "Breakdown", + "label.browser": "Browser", + "label.browsers": "உலாவிகள்", + "label.campaigns": "Campaigns", + "label.cancel": "ரத்துசெய்", + "label.change-password": "கடவுச்சொல்லை மாற்று", + "label.channels": "Channels", + "label.cities": "Cities", + "label.city": "City", + "label.clear-all": "Clear all", + "label.cohort": "Cohort", + "label.compare": "Compare", + "label.compare-dates": "Compare dates", + "label.confirm": "Confirm", + "label.confirm-password": "கடவுச்சொல்லை உறுதிப்படுத்தவும்", + "label.contains": "Contains", + "label.content": "Content", + "label.continue": "Continue", + "label.conversion": "Conversion", + "label.conversion-rate": "Conversion rate", + "label.conversion-step": "Conversion step", + "label.count": "Count", + "label.countries": "நாடுகள்", + "label.country": "Country", + "label.create": "Create", + "label.create-report": "Create report", + "label.create-team": "Create team", + "label.create-user": "Create user", + "label.created": "Created", + "label.created-by": "Created By", + "label.currency": "Currency", + "label.current": "Current", + "label.current-password": "தற்போதைய கடவுச்சொல்", + "label.custom-range": "தனிப்பயன் வேறுபாட்டெல்லை", + "label.dashboard": "முகப்பு", + "label.data": "Data", + "label.date": "Date", + "label.date-range": "தேதி வரம்பு", + "label.day": "Day", + "label.default-date-range": "இயல்புநிலை தேதி வரம்பு", + "label.delete": "அழி", + "label.delete-report": "Delete report", + "label.delete-team": "Delete team", + "label.delete-user": "Delete user", + "label.delete-website": "வலைத்தளத்தை நீக்கு", + "label.description": "Description", + "label.desktop": "மேசை கணினி", + "label.details": "Details", + "label.device": "Device", + "label.devices": "சாதனங்கள்", + "label.direct": "Direct", + "label.dismiss": "நீக்கு", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "Does not contain", + "label.does-not-include": "Does not include", + "label.doest-not-exist": "Does not exist", + "label.domain": "கள முகவரி", + "label.dropoff": "Dropoff", + "label.edit": "திருத்துதல்", + "label.edit-dashboard": "Edit dashboard", + "label.edit-member": "Edit member", + "label.email": "Email", + "label.enable-share-url": "கள முகவரியை பகிரலாம்", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "Event", + "label.event-data": "Event data", + "label.event-name": "Event name", + "label.events": "நிகழ்வுகள்", + "label.exists": "Exists", + "label.exit": "Exit URL", + "label.false": "False", + "label.field": "Field", + "label.fields": "Fields", + "label.filter": "Filter", + "label.filter-combined": "ஒருங்கிணைந்த", + "label.filter-raw": "மூல", + "label.filters": "Filters", + "label.first-click": "First click", + "label.first-seen": "First seen", + "label.funnel": "Funnel", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Funnels", + "label.goal": "Goal", + "label.goals": "Goals", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "Greater than", + "label.greater-than-equals": "Greater than or equals", + "label.grouped": "Grouped", + "label.hostname": "Hostname", + "label.includes": "Includes", + "label.insight": "Insight", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "Is", + "label.is-false": "Is false", + "label.is-not": "Is not", + "label.is-not-set": "Is not set", + "label.is-set": "Is set", + "label.is-true": "Is true", + "label.join": "Join", + "label.join-team": "Join team", + "label.journey": "Journey", + "label.journey-description": "Understand how users navigate through your website.", + "label.journeys": "Journeys", + "label.language": "Language", + "label.languages": "Languages", + "label.laptop": "மடிக்கணினி", + "label.last-click": "Last click", + "label.last-days": "முந்தைய {x} நாட்கள்", + "label.last-hours": "முந்தைய {x} மணி", + "label.last-months": "Last {x} months", + "label.last-seen": "Last seen", + "label.leave": "Leave", + "label.leave-team": "Leave team", + "label.less-than": "Less than", + "label.less-than-equals": "Less than or equals", + "label.links": "Links", + "label.login": "உள்நுழைய", + "label.logout": "வெளியேறு", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Expand", + "label.medium": "Medium", + "label.member": "Member", + "label.members": "Members", + "label.min": "Min", + "label.mobile": "கைபேசி", + "label.model": "Model", + "label.more": "மேலும்", + "label.my-account": "My account", + "label.my-websites": "My websites", + "label.name": "பெயர்", + "label.new-password": "புதிய கடவுச்சொல்", + "label.none": "None", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organic search", + "label.organic-shopping": "Organic shopping", + "label.organic-social": "Organic social", + "label.organic-video": "Organic video", + "label.os": "OS", + "label.other": "Other", + "label.overview": "Overview", + "label.owner": "Owner", + "label.page": "Page", + "label.page-of": "Page {current} of {total}", + "label.page-views": "பக்க காட்சிகள்", + "label.pageTitle": "Page title", + "label.pages": "பக்கங்கள்", + "label.paid-ads": "Paid ads", + "label.paid-search": "Paid search", + "label.paid-shopping": "Paid shopping", + "label.paid-social": "Paid social", + "label.paid-video": "Paid video", + "label.password": "கடவுச்சொல்", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "Pixels", + "label.powered-by": "{name} ஆல் இயக்கப்படுகிறது", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "சுயவிவரம்", + "label.properties": "Properties", + "label.property": "Property", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "தற்போதைய", + "label.referral": "Referral", + "label.referrer": "Referrer", + "label.referrers": "குறிப்பிடுவோர்", + "label.refresh": "புதுப்பிப்பு", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Remaining", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "தேவையானவை", + "label.reset": "மீட்டமை", + "label.reset-website": "Reset statistics", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "சேமி", + "label.screens": "Screens", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Select filter", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Session", + "label.session-data": "Session data", + "label.sessions": "Sessions", + "label.settings": "அமைப்புகள்", + "label.share": "Share", + "label.share-url": "வலைத்தள களத்தைப் பகிரவும்", + "label.single-day": "ஒரு நாள்", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "கையடக்க கணினி", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Team settings", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Terms", + "label.theme": "Theme", + "label.this-month": "இந்த மாதம்", + "label.this-week": "இந்த வாரம்", + "label.this-year": "இந்த வருடம்", + "label.timezone": "நேர மண்டலம்", + "label.title": "Title", + "label.today": "இன்று", + "label.toggle-charts": "Toggle charts", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "கண்காணிப்பு குறியீடு", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "தனிப்பட்ட பார்வையாளர்கள்", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "தெரியாத", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "பயனர்பெயர்", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "விபரங்களை பார்", + "label.view-only": "View only", + "label.views": "பார்வைகள்", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "சராசரி வருகை நேரம்", + "label.visitors": "பார்வையாளர்கள்", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "வலைத்தளங்கள்", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} தற்போதைய {x, plural, one {ஒன்று} other {மற்ற}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "நீங்கள் நிச்சயமாக {target} நீக்க விரும்புகிறீர்களா?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "Are your sure you want to reset {target}'s statistics?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "தொடர்புடைய எல்லா தரவும் நீக்கப்படும்.", + "message.error": "ஏதோ தவறு நடந்துவிட்டது.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "அமைப்புகளுக்குச் செல்லவும்", + "message.incorrect-username-password": "தவறான பயனர்பெயர் / கடவுச்சொல்.", + "message.invalid-domain": "தவறான கள முகவரி", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "தரவு எதுவும் கிடைக்கவில்லை.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "இருக்கடவுச்சொல் பொருந்தவில்லை", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "உங்களிடம் எந்த வலைத்தளங்களும் கட்டமைக்கப்படவில்லை.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "பக்கம் கிடைக்கவில்லை.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "All statistics for this website will be deleted, but your tracking code will remain intact.", + "message.saved": "வெற்றிகரமாக சேமிக்கப்பட்டது.", + "message.sever-error": "Server error", + "message.share-url": "{target} இது பொதுவில் பகிரும் வலைத்தள முகவரி.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "கண்காணிப்பு குறியீடு", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "{country}வில் இருந்து பார்வையாளர் {browser} ஐ {os} {device}லில் பயன்படுத்துகிறார்" +} diff --git a/src/lang/th-TH.json b/src/lang/th-TH.json new file mode 100644 index 0000000..b94ca90 --- /dev/null +++ b/src/lang/th-TH.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Access code", + "label.actions": "การกระทำ", + "label.activity": "Activity log", + "label.add": "Add", + "label.add-board": "Add board", + "label.add-description": "Add description", + "label.add-member": "Add member", + "label.add-step": "Add step", + "label.add-website": "เพิ่มเว็บไซต์", + "label.admin": "ผู้ดูแลระบบ", + "label.affiliate": "Affiliate", + "label.after": "After", + "label.all": "ทั้งหมด", + "label.all-time": "ทุกช่วงเวลา", + "label.analytics": "Analytics", + "label.apply": "Apply", + "label.attribution": "Attribution", + "label.attribution-description": "See how users engage with your marketing and what drives conversions.", + "label.average": "Average", + "label.back": "ย้อนกลับ", + "label.before": "Before", + "label.behavior": "พฤติกรรม", + "label.boards": "Boards", + "label.bounce-rate": "อัตราตีกลับ", + "label.breakdown": "Breakdown", + "label.browser": "Browser", + "label.browsers": "เบราว์เซอร์", + "label.campaigns": "Campaigns", + "label.cancel": "ยกเลิก", + "label.change-password": "เปลี่ยนรหัสผ่าน", + "label.channels": "Channels", + "label.cities": "Cities", + "label.city": "City", + "label.clear-all": "Clear all", + "label.cohort": "Cohort", + "label.compare": "Compare", + "label.compare-dates": "Compare dates", + "label.confirm": "Confirm", + "label.confirm-password": "ยืนยันรหัสผ่าน", + "label.contains": "Contains", + "label.content": "Content", + "label.continue": "Continue", + "label.conversion": "Conversion", + "label.conversion-rate": "Conversion rate", + "label.conversion-step": "Conversion step", + "label.count": "Count", + "label.countries": "ประเทศ", + "label.country": "Country", + "label.create": "Create", + "label.create-report": "Create report", + "label.create-team": "Create team", + "label.create-user": "Create user", + "label.created": "Created", + "label.created-by": "Created By", + "label.currency": "Currency", + "label.current": "Current", + "label.current-password": "รหัสผ่านปัจจุบัน", + "label.custom-range": "กำหนดช่วงเวลา", + "label.dashboard": "แดชบอร์ด", + "label.data": "Data", + "label.date": "Date", + "label.date-range": "ตั้งแต่วันที่", + "label.day": "Day", + "label.default-date-range": "ช่วงเวลา", + "label.delete": "ลบ", + "label.delete-report": "Delete report", + "label.delete-team": "Delete team", + "label.delete-user": "Delete user", + "label.delete-website": "ลบเว็บไซต์", + "label.description": "Description", + "label.desktop": "เดสก์ท็อป", + "label.details": "Details", + "label.device": "Device", + "label.devices": "อุปกรณ์", + "label.direct": "Direct", + "label.dismiss": "ยกเลิก", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "Does not contain", + "label.does-not-include": "Does not include", + "label.doest-not-exist": "Does not exist", + "label.domain": "โดเมน", + "label.dropoff": "Dropoff", + "label.edit": "แก้ไข", + "label.edit-dashboard": "Edit dashboard", + "label.edit-member": "Edit member", + "label.email": "Email", + "label.enable-share-url": "เปิดใช้งานการแชร์ลิงก์", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "Event", + "label.event-data": "Event data", + "label.event-name": "Event name", + "label.events": "เหตุการณ์", + "label.exists": "Exists", + "label.exit": "Exit URL", + "label.false": "False", + "label.field": "Field", + "label.fields": "Fields", + "label.filter": "Filter", + "label.filter-combined": "ข้อมูลรวม", + "label.filter-raw": "ข้อมูลดิบ", + "label.filters": "Filters", + "label.first-click": "First click", + "label.first-seen": "First seen", + "label.funnel": "Funnel", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Funnels", + "label.goal": "Goal", + "label.goals": "Goals", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "Greater than", + "label.greater-than-equals": "Greater than or equals", + "label.grouped": "Grouped", + "label.hostname": "Hostname", + "label.includes": "Includes", + "label.insight": "Insight", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "Is", + "label.is-false": "Is false", + "label.is-not": "Is not", + "label.is-not-set": "Is not set", + "label.is-set": "Is set", + "label.is-true": "Is true", + "label.join": "Join", + "label.join-team": "Join team", + "label.journey": "Journey", + "label.journey-description": "Understand how users navigate through your website.", + "label.journeys": "Journeys", + "label.language": "ภาษา", + "label.languages": "ภาษา", + "label.laptop": "แล็ปท็อป", + "label.last-click": "Last click", + "label.last-days": "{x} วันที่ผ่านมา", + "label.last-hours": "{x} ชั่วโมงที่ผ่านมา", + "label.last-months": "Last {x} months", + "label.last-seen": "Last seen", + "label.leave": "Leave", + "label.leave-team": "Leave team", + "label.less-than": "Less than", + "label.less-than-equals": "Less than or equals", + "label.links": "Links", + "label.login": "เข้าสู่ระบบ", + "label.logout": "ออกจากระบบ", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Expand", + "label.medium": "Medium", + "label.member": "Member", + "label.members": "Members", + "label.min": "Min", + "label.mobile": "โทรศัพท์มือถือ", + "label.model": "Model", + "label.more": "เพิ่มเติม", + "label.my-account": "My account", + "label.my-websites": "My websites", + "label.name": "ชื่อ", + "label.new-password": "รหัสผ่านใหม่", + "label.none": "ไม่ได้กำหนด", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organic search", + "label.organic-shopping": "Organic shopping", + "label.organic-social": "Organic social", + "label.organic-video": "Organic video", + "label.os": "OS", + "label.other": "Other", + "label.overview": "Overview", + "label.owner": "เจ้าของ", + "label.page": "Page", + "label.page-of": "Page {current} of {total}", + "label.page-views": "การเข้าชม", + "label.pageTitle": "Page title", + "label.pages": "หน้าเพจ", + "label.paid-ads": "Paid ads", + "label.paid-search": "Paid search", + "label.paid-shopping": "Paid shopping", + "label.paid-social": "Paid social", + "label.paid-video": "Paid video", + "label.password": "รหัสผ่าน", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "Pixels", + "label.powered-by": "ขับเคลื่อนโดย {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "โปรไฟล์", + "label.properties": "Properties", + "label.property": "Property", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "เรียลไทม์", + "label.referral": "Referral", + "label.referrer": "Referrer", + "label.referrers": "แหล่งที่มา", + "label.refresh": "รีเฟรช", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Remaining", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "ต้องการ", + "label.reset": "รีเซต", + "label.reset-website": "รีเซตข้อมูลสถิติ", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "บันทึก", + "label.screens": "ขนาดหน้าจอ", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Select filter", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Session", + "label.session-data": "Session data", + "label.sessions": "Sessions", + "label.settings": "ตั้งค่า", + "label.share": "Share", + "label.share-url": "แชร์ลิงก์", + "label.single-day": "วันที่", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "แท็บเล็ต", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Team settings", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Terms", + "label.theme": "ธีม", + "label.this-month": "เดือนปัจจุบัน", + "label.this-week": "สัปดาห์ปัจจุบัน", + "label.this-year": "ปีปัจจุบัน", + "label.timezone": "เขตเวลา", + "label.title": "Title", + "label.today": "วันนี้", + "label.toggle-charts": "เปิด/ปิดแผนภูมิ", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "โค้ดสำหรับใช้ติดตาม", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "ผู้เข้าชม", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "ไม่รู้จัก", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "ชื่อผู้ใช้", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "แสดงรายละเอียด", + "label.view-only": "View only", + "label.views": "การเข้าชม", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "ระยะเวลาเข้าชมเฉลี่ย", + "label.visitors": "ผู้เข้าชม", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "เว็บไซต์", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "มีผู้ใช้งาน {x} {x, plural, one {คนในขณะนี้} other {คนในขณะนี้}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "คุณแน่ใจหรือไม่ว่าต้องการลบ {target} ?", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "คุณแน่ใจหรือไม่ว่าต้องการรีเซตข้อมูลสถิติของ {target} ?", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "ข้อมูลที่เกี่ยวข้องทั้งหมดจะถูกลบ.", + "message.error": "เกิดข้อผิดพลาด.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "ไปที่การตั้งค่า", + "message.incorrect-username-password": "ชื่อผู้ใช้หรือรหัสผ่านไม่ถูกต้อง.", + "message.invalid-domain": "โดเมนไม่ถูกต้อง", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "ไม่มีข้อมูล.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "รหัสผ่านไม่ตรงกัน", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "คุณยังไม่ได้ตั้งค่าเว็บไซต์ใด ๆ ไว้.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "ไม่พบหน้านี้.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "สถิติทั้งหมดสำหรับเว็บไซต์นี้จะถูกลบออก แต่โค้ดสำหรับใช้ติดตามของคุณจะยังคงอยู่เหมือนเดิม.", + "message.saved": "บันทึกข้อมูลเรียบร้อย.", + "message.sever-error": "Server error", + "message.share-url": "นี่คือลิงก์ที่แชร์แบบสาธารณะสำหรับ {target}.", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "โค้ดสำหรับใช้ติดตาม", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "ผู้เข้าชมจาก {country} กำลังใช้งานผ่าน {browser} บน {os} {device}" +} diff --git a/src/lang/tr-TR.json b/src/lang/tr-TR.json new file mode 100644 index 0000000..3a2dce4 --- /dev/null +++ b/src/lang/tr-TR.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Erişim Kodu", + "label.actions": "Hareketler", + "label.activity": "Aktivite Kaydı", + "label.add": "Ekle", + "label.add-board": "Pano ekle", + "label.add-description": "Açıklama ekle", + "label.add-member": "Üye ekle", + "label.add-step": "Adım ekle", + "label.add-website": "Web sitesi ekle", + "label.admin": "Administrator", + "label.affiliate": "Ortak", + "label.after": "Sonra", + "label.all": "Tümü", + "label.all-time": "Tüm zamanlar", + "label.analytics": "Analitik", + "label.apply": "Uygula", + "label.attribution": "Atıf", + "label.attribution-description": "Kullanıcıların pazarlamanızla nasıl etkileşime girdiğini ve dönüşümleri neyin tetiklediğini görün.", + "label.average": "Ortalama", + "label.back": "Geri", + "label.before": "Önce", + "label.behavior": "Davranış", + "label.boards": "Panolar", + "label.bounce-rate": "Tek sayfa ziyaret oranı", + "label.breakdown": "Dağılım", + "label.browser": "Tarayıcı", + "label.browsers": "Tarayıcılar", + "label.campaigns": "Kampanyalar", + "label.cancel": "İptal", + "label.change-password": "Şifre değiştir", + "label.channels": "Kanallar", + "label.cities": "Şehirler", + "label.city": "Şehir", + "label.clear-all": "Hepsini temizle", + "label.cohort": "Kohort", + "label.compare": "Karşılaştır", + "label.compare-dates": "Tarihleri karşılaştır", + "label.confirm": "Onayla", + "label.confirm-password": "Parolayı onayla", + "label.contains": "İçeriği", + "label.content": "İçerik", + "label.continue": "Devam et", + "label.conversion": "Dönüşüm", + "label.conversion-rate": "Dönüşüm oranı", + "label.conversion-step": "Dönüşüm adımı", + "label.count": "Adet", + "label.countries": "Ülkeler", + "label.country": "Ülke", + "label.create": "Oluştur", + "label.create-report": "Rapor oluştur", + "label.create-team": "Takım oluştur", + "label.create-user": "Kullanıcı oluştur", + "label.created": "Oluşturuldu", + "label.created-by": "Tarafından oluşturldu", + "label.currency": "Para birimi", + "label.current": "Mevcut", + "label.current-password": "Mevcut parola", + "label.custom-range": "Özelleştirilmiş aralık", + "label.dashboard": "Kontrol Paneli", + "label.data": "Veri", + "label.date": "Tarih", + "label.date-range": "Tarih aralığı", + "label.day": "Gün", + "label.default-date-range": "Varsayılan tarih aralığı", + "label.delete": "Sil", + "label.delete-report": "Rapor sil", + "label.delete-team": "Takım sil", + "label.delete-user": "Kullanıcı sil", + "label.delete-website": "Web sitesini sil", + "label.description": "Açıklama", + "label.desktop": "Masaüstü", + "label.details": "Detaylar", + "label.device": "Cihaz", + "label.devices": "Cihazlar", + "label.direct": "Doğrudan", + "label.dismiss": "Reddet", + "label.distinct-id": "Benzersiz ID", + "label.does-not-contain": "İçermez", + "label.does-not-include": "İçermiyor", + "label.doest-not-exist": "Mevcut değil", + "label.domain": "Alan adı", + "label.dropoff": "Bırakma", + "label.edit": "Düzenle", + "label.edit-dashboard": "Kontrol panelini düzenle", + "label.edit-member": "Üyeyi düzenle", + "label.email": "Email", + "label.enable-share-url": "Anonim paylaşım URL'i aktif", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "Olay", + "label.event-data": "Olay verisi", + "label.event-name": "Olay adı", + "label.events": "Olaylar", + "label.exists": "Mevcut", + "label.exit": "Exit URL", + "label.false": "Yanlış", + "label.field": "Alan", + "label.fields": "Alanlar", + "label.filter": "Filtre", + "label.filter-combined": "Birleşik filtre", + "label.filter-raw": "Ham filtre", + "label.filters": "Filtreler", + "label.first-click": "İlk tıklama", + "label.first-seen": "First seen", + "label.funnel": "Huni", + "label.funnel-description": "Kullanıcıların dönüşüm ve ayrılma oranlarını anlayın.", + "label.funnels": "Huniler", + "label.goal": "Hedef", + "label.goals": "Hedefler", + "label.goals-description": "Sayfa görüntüleme ve olaylar için hedeflerinizi takip edin.", + "label.greater-than": "Büyüktür", + "label.greater-than-equals": "Büyük veya eşittir", + "label.grouped": "Gruplandırılmış", + "label.hostname": "Sunucu adı", + "label.includes": "İçerir", + "label.insight": "İçgörü", + "label.insights": "Insights", + "label.insights-description": "Segmentleri ve filtreleri kullanarak verilerinizi derinlemesine inceleyin.", + "label.is": "Is", + "label.is-false": "Yanlış", + "label.is-not": "Değil", + "label.is-not-set": "Ayarlanmamış", + "label.is-set": "Ayarlandı", + "label.is-true": "Doğru", + "label.join": "Katıl", + "label.join-team": "Takıma katıl", + "label.journey": "Yolculuk", + "label.journey-description": "Kullanıcıların sitenizde nasıl gezindiğini anlayın.", + "label.journeys": "Yolculuklar", + "label.language": "Dil", + "label.languages": "Diller", + "label.laptop": "Dizüstü", + "label.last-click": "Son tıklama", + "label.last-days": "Son {x} gün", + "label.last-hours": "Son {x} saat", + "label.last-months": "Son {x} ay", + "label.last-seen": "Son görüldü", + "label.leave": "Ayrıl", + "label.leave-team": "Takımdan Ayrıl", + "label.less-than": "Küçüktür", + "label.less-than-equals": "Küçük veya eşittir", + "label.links": "Bağlantılar", + "label.login": "Giriş Yap", + "label.logout": "Çıkış Yap", + "label.manage": "Yönet", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Genişlet", + "label.medium": "Orta", + "label.member": "Üye", + "label.members": "Üyeler", + "label.min": "Min", + "label.mobile": "Mobil Cihaz", + "label.model": "Model", + "label.more": "Detaylı göster", + "label.my-account": "Hesabım", + "label.my-websites": "Web sitelerim", + "label.name": "İsim", + "label.new-password": "Yeni parola", + "label.none": "Yok", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "TAMAM", + "label.online": "Online", + "label.organic-search": "Organik arama", + "label.organic-shopping": "Organik alışveriş", + "label.organic-social": "Organik sosyal", + "label.organic-video": "Organik video", + "label.os": "OS", + "label.other": "Diğer", + "label.overview": "Genel bakış", + "label.owner": "Sahibi", + "label.page": "Sayfa", + "label.page-of": "{total} sayfada {current} ", + "label.page-views": "Sayfa görünümü", + "label.pageTitle": "Sayfa başlığı", + "label.pages": "Sayfalar", + "label.paid-ads": "Ücretli reklamlar", + "label.paid-search": "Ücretli arama", + "label.paid-shopping": "Ücretli alışveriş", + "label.paid-social": "Ücretli sosyal", + "label.paid-video": "Ücretli video", + "label.password": "Parola", + "label.path": "Yol", + "label.paths": "Yollar", + "label.pixels": "Pikseller", + "label.powered-by": "Sağlayıcı: {name}", + "label.previous": "Önceki", + "label.previous-period": "Önceki dönem", + "label.previous-year": "Önceki yıl", + "label.profile": "Profil", + "label.properties": "Özellikler", + "label.property": "Özellik", + "label.queries": "Sorgular", + "label.query": "Sorgu", + "label.query-parameters": "Sorgu parametreleri", + "label.realtime": "Gerçek Zamanlı", + "label.referral": "Yönlendirme", + "label.referrer": "Referrer", + "label.referrers": "Yönlendirenler", + "label.refresh": "Yenile", + "label.regenerate": "Yeniden Oluştur", + "label.region": "Bölge", + "label.regions": "Bölgeler", + "label.remaining": "Kalan", + "label.remove": "Kaldır", + "label.remove-member": "Üyeyi kaldır", + "label.reports": "Raporlar", + "label.required": "Zorunlu alan", + "label.reset": "Sıfırla", + "label.reset-website": "İstatistikleri sıfırla", + "label.retention": "Geri dönüş", + "label.retention-description": "Kullanıcıların ne sıklıkla geri döndüğünü takip ederek web sitenizin kalıcılığını ölçün.", + "label.revenue": "Gelir", + "label.revenue-description": "Gelirinizi zaman içinde inceleyin.", + "label.role": "Rol", + "label.run-query": "Sorgu çalıştır", + "label.save": "Kaydet", + "label.screens": "Ekranlar", + "label.search": "Ara", + "label.select": "Seç", + "label.select-date": "Tarih seç", + "label.select-filter": "Filtre seç", + "label.select-role": "Rol seç", + "label.select-website": "Web sitesi seç", + "label.session": "Oturum", + "label.session-data": "Oturum verisi", + "label.sessions": "Sessions", + "label.settings": "Ayarlar", + "label.share": "Paylaş", + "label.share-url": "Paylaşım adresi", + "label.single-day": "Tekil gün", + "label.sms": "SMS", + "label.sources": "Kaynaklar", + "label.start-step": "Start Step", + "label.steps": "Adımlar", + "label.sum": "Toplam", + "label.tablet": "Tablet", + "label.tag": "Etiket", + "label.tags": "Etiketler", + "label.team": "Takım", + "label.team-id": "Takım ID", + "label.team-manager": "Takım yöneticisi", + "label.team-member": "Takım üyesi", + "label.team-name": "Takım ismi", + "label.team-owner": "Takım sahibi", + "label.team-settings": "Takım ayarları", + "label.team-view-only": "Yalnızca ekip görünümü", + "label.team-websites": "Takım web siteleri", + "label.teams": "Takımlar", + "label.terms": "Koşullar", + "label.theme": "Tema", + "label.this-month": "Bu ay", + "label.this-week": "Bu hafta", + "label.this-year": "Bu yıl", + "label.timezone": "Zaman dilimi", + "label.title": "Başlık", + "label.today": "Bugün", + "label.toggle-charts": "Grafikleri değiştir", + "label.total": "Toplam", + "label.total-records": "Toplam kayıt", + "label.tracking-code": "İzleme kodu", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer web sitesi", + "label.true": "Doğru", + "label.type": "Tip", + "label.unique": "Benzersiz", + "label.unique-visitors": "Tekil kullanıcı", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Bilinmeyen", + "label.untitled": "İsimsiz", + "label.update": "Güncelle", + "label.user": "Kullanıcı", + "label.username": "Kullanıcı adı", + "label.users": "Kullanıcılar", + "label.utm": "UTM", + "label.utm-description": "Kampanyalarınızı UTM parametreleri aracılığıyla takip edin.", + "label.value": "Değer", + "label.view": "Görünüm", + "label.view-details": "Detayı incele", + "label.view-only": "Sadece görünüm", + "label.views": "Görüntüleme", + "label.views-per-visit": "Ziyaret başına görüntüleme", + "label.visit-duration": "Ortalama ziyaret süresi", + "label.visitors": "Ziyaretçi", + "label.visits": "Ziyaretler", + "label.website": "Web sitesi", + "label.website-id": "Website ID", + "label.websites": "Web siteleri", + "label.window": "Pencere", + "label.yesterday": "Dün", + "message.action-confirmation": "Onaylamak için aşağıdaki kutuya {confirmation} yazın.", + "message.active-users": "{x} aktif ziyaretçi", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "{target} kaydını silmek istediğinizden emin misiniz?", + "message.confirm-leave": "{target} kaydından ayrılmak istediğinizden emin misiniz?", + "message.confirm-remove": "{target} kaydını kaldırmak istediğinizden emin misiniz?", + "message.confirm-reset": "{target} istatistiklerini sıfırlamak istediğinizden emin misiniz?", + "message.delete-team-warning": "Bir takımı silmek tüm takım web sitelerini de silecektir.", + "message.delete-website-warning": "İlişkili tüm veriler de silinecektir.", + "message.error": "Bir şeyler ters gitti!", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Ayarlara git", + "message.incorrect-username-password": "Hatalı kullanıcı adı ya da parola.", + "message.invalid-domain": "Geçersiz alan adı", + "message.min-password-length": "Minimum {n} karakter uzunluğu", + "message.new-version-available": "Yeni versiyon Umami {version} mevcut!", + "message.no-data-available": "Henüz hiç veri yok.", + "message.no-event-data": "Hiçbir olay verisi mevcut değil.", + "message.no-match-password": "Parolalar uyuşmuyor", + "message.no-results-found": "Hiçbir sonuç bulunamadı.", + "message.no-team-websites": "Bu takımın herhangi bir web sitesi yok.", + "message.no-teams": "Herhangi bir takım oluşturmadınız.", + "message.no-users": "Kullanıcı yok.", + "message.no-websites-configured": "Henüz hiç web sitesi tanımlamadınız", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Sayfa bulunamadı.", + "message.reset-website": "Bu websitesini sıfılamak için aşağıdaki kutuya {confirmation} yazın.", + "message.reset-website-warning": "Bu web sitesi için tüm istatistikler silinecek, ancak izleme kodunuz bozulmadan kalacaktır.", + "message.saved": "Başarıyla kaydedildi.", + "message.sever-error": "Server error", + "message.share-url": "{target} için kullanılabilir anonim paylaşım adresidir.", + "message.team-already-member": "Zaten bu takımın üyesisiniz", + "message.team-not-found": "Takım bulunamadı", + "message.team-websites-info": "Web siteleri takımdaki herkes tarafından görüntülenebilir.", + "message.tracking-code": "İzleme kodu", + "message.transfer-team-website-to-user": "Bu web sitesi hesbınıza aktarılsın mı?", + "message.transfer-user-website-to-team": "Bu web sitesinin aktarılacağı takımı seçin.", + "message.transfer-website": "Web sitesi sahipliğini hesabınıza veya başka bir takıma aktarın", + "message.triggered-event": "Tetiklenen olay", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Kullanıcı silindi.", + "message.viewed-page": "Görüntülenen sayfa", + "message.visitor-log": "Yeni ziyaretçi: {country}, {os}, {device}, {browser}" +} diff --git a/src/lang/uk-UA.json b/src/lang/uk-UA.json new file mode 100644 index 0000000..768015b --- /dev/null +++ b/src/lang/uk-UA.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Код доступу", + "label.actions": "Дії", + "label.activity": "Журнал", + "label.add": "Додати", + "label.add-board": "Додати дошку", + "label.add-description": "Додати опис", + "label.add-member": "Додати учасника", + "label.add-step": "Додати крок", + "label.add-website": "Додати сайт", + "label.admin": "Адміністратор", + "label.affiliate": "Партнер", + "label.after": "Після", + "label.all": "Всі", + "label.all-time": "Весь час", + "label.analytics": "Аналітика", + "label.apply": "Застосувати", + "label.attribution": "Атрибуція", + "label.attribution-description": "Дивіться, як користувачі взаємодіють з вашим маркетингом і що сприяє конверсіям.", + "label.average": "Середній", + "label.back": "Назад", + "label.before": "До", + "label.behavior": "Поведінка", + "label.boards": "Дошки", + "label.bounce-rate": "Показник відмов", + "label.breakdown": "Розподіл", + "label.browser": "Браузер", + "label.browsers": "Браузери", + "label.campaigns": "Кампанії", + "label.cancel": "Відмінити", + "label.change-password": "Змінити пароль", + "label.channels": "Канали", + "label.cities": "Міста", + "label.city": "Місто", + "label.clear-all": "Очистити все", + "label.cohort": "Когорта", + "label.compare": "Порівняти", + "label.compare-dates": "Порівняти дати", + "label.confirm": "Підтвердити", + "label.confirm-password": "Підтвердити пароль", + "label.contains": "Містить", + "label.content": "Вміст", + "label.continue": "Продовжити", + "label.conversion": "Конверсія", + "label.conversion-rate": "Рівень конверсії", + "label.conversion-step": "Крок конверсії", + "label.count": "Кількість", + "label.countries": "Країни", + "label.country": "Країна", + "label.create": "Створити", + "label.create-report": "Створити звіт", + "label.create-team": "Створити команду", + "label.create-user": "Створити користувача", + "label.created": "Створено", + "label.created-by": "Створено", + "label.currency": "Валюта", + "label.current": "Поточний", + "label.current-password": "Поточний пароль", + "label.custom-range": "Довільний період", + "label.dashboard": "Інформаційна панель", + "label.data": "Дані", + "label.date": "Дата", + "label.date-range": "Діапазон дат", + "label.day": "День", + "label.default-date-range": "Діапазон дат за замовчуванням", + "label.delete": "Видалити", + "label.delete-report": "Видалити звіт", + "label.delete-team": "Видалити команду", + "label.delete-user": "Видалити користувача", + "label.delete-website": "Видалити сайт", + "label.description": "Опис", + "label.desktop": "Настільний ПК", + "label.details": "Деталі", + "label.device": "Пристрій", + "label.devices": "Пристрої", + "label.direct": "Прямий", + "label.dismiss": "Відхилити", + "label.distinct-id": "Унікальний ID", + "label.does-not-contain": "Не містить", + "label.does-not-include": "Не включає", + "label.doest-not-exist": "Не існує", + "label.domain": "Домен", + "label.dropoff": "Відсів", + "label.edit": "Редагувати", + "label.edit-dashboard": "Редагувати панель", + "label.edit-member": "Редагувати учасника", + "label.email": "Email", + "label.enable-share-url": "Увімкнути спільне посилання", + "label.end-step": "Кінцевий крок", + "label.entry": "Вхідний URL", + "label.event": "Подія", + "label.event-data": "Дані події", + "label.event-name": "Назва події", + "label.events": "Події", + "label.exists": "Існує", + "label.exit": "Exit URL", + "label.false": "False", + "label.field": "Поле", + "label.fields": "Поля", + "label.filter": "Фільтр", + "label.filter-combined": "Об'єднані", + "label.filter-raw": "Сирі дані", + "label.filters": "Фільтри", + "label.first-click": "Перший клік", + "label.first-seen": "First seen", + "label.funnel": "Воронка", + "label.funnel-description": "Зрозуміти рівень конверсії та відсіву користувачів.", + "label.funnels": "Воронки", + "label.goal": "Мета", + "label.goals": "Мети", + "label.goals-description": "Відстежуйте свої цілі для переглядів сторінок і подій.", + "label.greater-than": "Більше ніж", + "label.greater-than-equals": "Більше або рівно", + "label.grouped": "Груповано", + "label.hostname": "Ім'я хоста", + "label.includes": "Включає", + "label.insight": "Інсайт", + "label.insights": "Інсайти", + "label.insights-description": "Зануртеся глибше у свої дані за допомогою сегментів та фільтрів.", + "label.is": "Є", + "label.is-false": "Хибно", + "label.is-not": "Не є", + "label.is-not-set": "Не встановлено", + "label.is-set": "Встановлено", + "label.is-true": "Правдиво", + "label.join": "Приєднатись", + "label.join-team": "Приєднатись до команди", + "label.journey": "Шлях", + "label.journey-description": "Зрозумійте, як користувачі переміщаються вашим сайтом.", + "label.journeys": "Шляхи", + "label.language": "Мова", + "label.languages": "Мови", + "label.laptop": "Ноутбук", + "label.last-click": "Останній клік", + "label.last-days": "Останні {x} днів", + "label.last-hours": "Останні {x} годин", + "label.last-months": "Останні {x} місяців", + "label.last-seen": "Останній перегляд", + "label.leave": "Покинути", + "label.leave-team": "Покинути команду", + "label.less-than": "Менше ніж", + "label.less-than-equals": "Менше або дорівнює", + "label.links": "Посилання", + "label.login": "Увійти", + "label.logout": "Вийти", + "label.manage": "Керувати", + "label.manager": "Manager", + "label.max": "Макс.", + "label.maximize": "Розгорнути", + "label.medium": "Середній", + "label.member": "Учасник", + "label.members": "Учасники", + "label.min": "Мін.", + "label.mobile": "Мобільний", + "label.model": "Модель", + "label.more": "Більше", + "label.my-account": "Мій обліковий запис", + "label.my-websites": "Мої сайти", + "label.name": "Ім'я", + "label.new-password": "Новий пароль", + "label.none": "Нічого", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Органічний пошук", + "label.organic-shopping": "Органічні покупки", + "label.organic-social": "Органічні соцмережі", + "label.organic-video": "Органічне відео", + "label.os": "ОС", + "label.other": "Інше", + "label.overview": "Огляд", + "label.owner": "Власник", + "label.page": "Сторінка", + "label.page-of": "Сторінка {current} з {total}", + "label.page-views": "Перегляди сторінок", + "label.pageTitle": "Заголовок сторінки", + "label.pages": "Сторінки", + "label.paid-ads": "Платна реклама", + "label.paid-search": "Платний пошук", + "label.paid-shopping": "Платні покупки", + "label.paid-social": "Платні соцмережі", + "label.paid-video": "Платне відео", + "label.password": "Пароль", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "Пікселі", + "label.powered-by": "На базі {name}", + "label.previous": "Попередній", + "label.previous-period": "Попередній період", + "label.previous-year": "Попередній рік", + "label.profile": "Профіль", + "label.properties": "Властивості", + "label.property": "Властивість", + "label.queries": "Запити", + "label.query": "Запит", + "label.query-parameters": "Параметри запиту", + "label.realtime": "У реальному часі", + "label.referral": "Реферал", + "label.referrer": "Джерело", + "label.referrers": "Джерела", + "label.refresh": "Оновити", + "label.regenerate": "Згенерувати знову", + "label.region": "Регіон", + "label.regions": "Регіони", + "label.remaining": "Залишилось", + "label.remove": "Видалити", + "label.remove-member": "Видалити користувача", + "label.reports": "Звіти", + "label.required": "Обов'язкове", + "label.reset": "Скинути", + "label.reset-website": "Скинути статистику сайту", + "label.retention": "Липкість", + "label.retention-description": "Виміряйте липкість вашого сайту, відстежуючи, як часто користувачі повертаються на нього.", + "label.revenue": "Дохід", + "label.revenue-description": "Перегляньте свій дохід за певний період.", + "label.role": "Роль", + "label.run-query": "Виконати запит", + "label.save": "Зберегти", + "label.screens": "Екрани", + "label.search": "Пошук", + "label.select": "Вибрати", + "label.select-date": "Вибрати дату", + "label.select-filter": "Вибрати фільтр", + "label.select-role": "Вибрати роль", + "label.select-website": "Вибрати сайт", + "label.session": "Сесія", + "label.session-data": "Дані сесії", + "label.sessions": "Сесії", + "label.settings": "Налаштування", + "label.share": "Поділитися", + "label.share-url": "Поділитися посилання", + "label.single-day": "Один день", + "label.sms": "SMS", + "label.sources": "Джерела", + "label.start-step": "Start Step", + "label.steps": "Кроки", + "label.sum": "Сума", + "label.tablet": "Планшет", + "label.tag": "Тег", + "label.tags": "Теги", + "label.team": "Команда", + "label.team-id": "Ідентифікатор команди", + "label.team-manager": "Менеджер команди", + "label.team-member": "Учасник команди", + "label.team-name": "Назва команди", + "label.team-owner": "Власник команди", + "label.team-settings": "Налаштування команди", + "label.team-view-only": "Тільки для командного перегляду", + "label.team-websites": "Сайти команди", + "label.teams": "Команди", + "label.terms": "Умови", + "label.theme": "Тема", + "label.this-month": "Цього місяця", + "label.this-week": "Цього тижня", + "label.this-year": "Цього ріку", + "label.timezone": "Часовий пояс", + "label.title": "Заголовок", + "label.today": "Сьогодні", + "label.toggle-charts": "Переключити графіки", + "label.total": "Всього", + "label.total-records": "Всього записів", + "label.tracking-code": "Код для відслідковування", + "label.transactions": "Transactions", + "label.transfer": "Передати", + "label.transfer-website": "Передати сайт", + "label.true": "True", + "label.type": "Тип", + "label.unique": "Унікальний", + "label.unique-visitors": "Унікальні відвідувачі", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "Невідомо", + "label.untitled": "Без заголовку", + "label.update": "Оновлення", + "label.user": "Користувач", + "label.username": "Ім'я користувача", + "label.users": "Користувачі", + "label.utm": "UTM", + "label.utm-description": "Відстежуйте свої кампанії за допомогою параметрів UTM.", + "label.value": "Значення", + "label.view": "Перегляд", + "label.view-details": "Переглянути деталі", + "label.view-only": "Тільки для перегляду", + "label.views": "Перегляди", + "label.views-per-visit": "Перегляди за одне відвідування", + "label.visit-duration": "Visit duration", + "label.visitors": "Відвідувачі", + "label.visits": "Відвідування", + "label.website": "Сайт", + "label.website-id": "Ідентифікатор сайту", + "label.websites": "Сайти", + "label.window": "Вікно", + "label.yesterday": "Вчора", + "message.action-confirmation": "Введіть {confirmation} у полі нижче, щоб підтвердити.", + "message.active-users": "{x} поточних відвідувачів", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "Ви впевнені, що бажаєте видалити {target}?", + "message.confirm-leave": "Ви впевнені, що бажаєте покинути {target}?", + "message.confirm-remove": "Ви впевнені, що бажаєте видалити {target}?", + "message.confirm-reset": "Ви впевнені, що бажаєте скинути статистику для {target}?", + "message.delete-team-warning": "Видалення команди також призведе до видалення всіх її веб-сайтів.", + "message.delete-website-warning": "Усі пов'язані дані будуть видалені також.", + "message.error": "Щось пішло не так.", + "message.event-log": "{event} на {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "Перейти до налаштувань", + "message.incorrect-username-password": "Невірне ім'я користувача або пароль.", + "message.invalid-domain": "Некоректний домен", + "message.min-password-length": "Мінімальна довжина {n} символів", + "message.new-version-available": "Вийшла нова версія Umami {version}!", + "message.no-data-available": "Немає даних.", + "message.no-event-data": "Дані про події відсутні.", + "message.no-match-password": "Паролі не співпадають", + "message.no-results-found": "Не знайдено жодного результату.", + "message.no-team-websites": "У цієї команди немає жодного веб-сайту.", + "message.no-teams": "Ви не створили жодної команди.", + "message.no-users": "Немає жодного користувача.", + "message.no-websites-configured": "У вас немає налаштованих сайтів.", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "Сторінку не знайдено.", + "message.reset-website": "Щоб скинути налаштування цього веб-сайту, введіть {confirmation} у полі нижче для підтвердження.", + "message.reset-website-warning": "Вся статистика для цього сайту буде видалена, проте код відслідковування буде продовжувати працювати.", + "message.saved": "Збережено успішно.", + "message.sever-error": "Server error", + "message.share-url": "Це публічне посилання для {target}.", + "message.team-already-member": "Ви вже є членом команди.", + "message.team-not-found": "Команду не знайдено.", + "message.team-websites-info": "Веб-сайти може переглядати будь-хто з команди.", + "message.tracking-code": "Код для відслідковування", + "message.transfer-team-website-to-user": "Перенести цей сайт до свого облікового запису?", + "message.transfer-user-website-to-team": "Виберіть команду, до якої ви хочете передати цей веб-сайт.", + "message.transfer-website": "Передайте право власності на сайт своєму акаунту або іншій команді.", + "message.triggered-event": "Подія, що спрацювала", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "Користувача видалено.", + "message.viewed-page": "Переглянута сторінка", + "message.visitor-log": "Відвідувач з {country} використовуючи {browser} на {os} {device}" +} diff --git a/src/lang/ur-PK.json b/src/lang/ur-PK.json new file mode 100644 index 0000000..5cc3121 --- /dev/null +++ b/src/lang/ur-PK.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "Access code", + "label.actions": "اعمال", + "label.activity": "Activity log", + "label.add": "Add", + "label.add-board": "Add board", + "label.add-description": "Add description", + "label.add-member": "Add member", + "label.add-step": "Add step", + "label.add-website": "ویب سائٹ کا اضافہ کریں", + "label.admin": "منتظم", + "label.affiliate": "Affiliate", + "label.after": "After", + "label.all": "تمام", + "label.all-time": "تمام وقت", + "label.analytics": "Analytics", + "label.apply": "Apply", + "label.attribution": "Attribution", + "label.attribution-description": "See how users engage with your marketing and what drives conversions.", + "label.average": "Average", + "label.back": "پیچھے", + "label.before": "Before", + "label.behavior": "رویے", + "label.boards": "Boards", + "label.bounce-rate": "اچھال کی شرح", + "label.breakdown": "Breakdown", + "label.browser": "Browser", + "label.browsers": "براؤزرز", + "label.campaigns": "Campaigns", + "label.cancel": "منسوخ", + "label.change-password": "پاس ورڈ تبدیل کریں", + "label.channels": "Channels", + "label.cities": "Cities", + "label.city": "City", + "label.clear-all": "Clear all", + "label.cohort": "Cohort", + "label.compare": "Compare", + "label.compare-dates": "Compare dates", + "label.confirm": "Confirm", + "label.confirm-password": "پاس ورڈ کی تصدیق کریں", + "label.contains": "Contains", + "label.content": "Content", + "label.continue": "Continue", + "label.conversion": "Conversion", + "label.conversion-rate": "Conversion rate", + "label.conversion-step": "Conversion step", + "label.count": "Count", + "label.countries": "ممالک", + "label.country": "Country", + "label.create": "Create", + "label.create-report": "Create report", + "label.create-team": "Create team", + "label.create-user": "Create user", + "label.created": "Created", + "label.created-by": "Created By", + "label.currency": "Currency", + "label.current": "Current", + "label.current-password": "موجودہ پاس ورڈ", + "label.custom-range": "اپنی مرضی کی حد", + "label.dashboard": "ڈیش بورڈ", + "label.data": "Data", + "label.date": "Date", + "label.date-range": "تاریخ کی حد", + "label.day": "Day", + "label.default-date-range": "پہلے سے طے شدہ تاریخ کی حد", + "label.delete": "حذف کریں", + "label.delete-report": "Delete report", + "label.delete-team": "Delete team", + "label.delete-user": "Delete user", + "label.delete-website": "ویب سائٹ مٹایں", + "label.description": "Description", + "label.desktop": "ڈیسک ٹاپ", + "label.details": "Details", + "label.device": "Device", + "label.devices": "آلات", + "label.direct": "Direct", + "label.dismiss": "مسترد کریں", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "Does not contain", + "label.does-not-include": "Does not include", + "label.doest-not-exist": "Does not exist", + "label.domain": "ڈومین", + "label.dropoff": "Dropoff", + "label.edit": "ترمیم", + "label.edit-dashboard": "Edit dashboard", + "label.edit-member": "Edit member", + "label.email": "Email", + "label.enable-share-url": "شیئر یو آر ایل کو فعال کریں", + "label.end-step": "End Step", + "label.entry": "Entry URL", + "label.event": "Event", + "label.event-data": "Event data", + "label.event-name": "Event name", + "label.events": "واقعات", + "label.exists": "Exists", + "label.exit": "Exit URL", + "label.false": "False", + "label.field": "Field", + "label.fields": "Fields", + "label.filter": "Filter", + "label.filter-combined": "مشترکہ", + "label.filter-raw": "خام", + "label.filters": "Filters", + "label.first-click": "First click", + "label.first-seen": "First seen", + "label.funnel": "Funnel", + "label.funnel-description": "Understand the conversion and drop-off rate of users.", + "label.funnels": "Funnels", + "label.goal": "Goal", + "label.goals": "Goals", + "label.goals-description": "Track your goals for pageviews and events.", + "label.greater-than": "Greater than", + "label.greater-than-equals": "Greater than or equals", + "label.grouped": "Grouped", + "label.hostname": "Hostname", + "label.includes": "Includes", + "label.insight": "Insight", + "label.insights": "Insights", + "label.insights-description": "Dive deeper into your data by using segments and filters.", + "label.is": "Is", + "label.is-false": "Is false", + "label.is-not": "Is not", + "label.is-not-set": "Is not set", + "label.is-set": "Is set", + "label.is-true": "Is true", + "label.join": "Join", + "label.join-team": "Join team", + "label.journey": "Journey", + "label.journey-description": "Understand how users navigate through your website.", + "label.journeys": "Journeys", + "label.language": "Language", + "label.languages": "زبانیں", + "label.laptop": "لیپ ٹاپ", + "label.last-click": "Last click", + "label.last-days": "پچھلے {x} دن", + "label.last-hours": "پچھلے {x} گھنٹے", + "label.last-months": "Last {x} months", + "label.last-seen": "Last seen", + "label.leave": "Leave", + "label.leave-team": "Leave team", + "label.less-than": "Less than", + "label.less-than-equals": "Less than or equals", + "label.links": "Links", + "label.login": "لاگ ان", + "label.logout": "لاگ آوٹ", + "label.manage": "Manage", + "label.manager": "Manager", + "label.max": "Max", + "label.maximize": "Expand", + "label.medium": "Medium", + "label.member": "Member", + "label.members": "Members", + "label.min": "Min", + "label.mobile": "موبائل", + "label.model": "Model", + "label.more": "مزید", + "label.my-account": "My account", + "label.my-websites": "My websites", + "label.name": "نام", + "label.new-password": "نیا پاس ورڈ", + "label.none": "None", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organic search", + "label.organic-shopping": "Organic shopping", + "label.organic-social": "Organic social", + "label.organic-video": "Organic video", + "label.os": "OS", + "label.other": "Other", + "label.overview": "Overview", + "label.owner": "مالک", + "label.page": "Page", + "label.page-of": "Page {current} of {total}", + "label.page-views": "صفحہ کے نظارے", + "label.pageTitle": "Page title", + "label.pages": "صفحات", + "label.paid-ads": "Paid ads", + "label.paid-search": "Paid search", + "label.paid-shopping": "Paid shopping", + "label.paid-social": "Paid social", + "label.paid-video": "Paid video", + "label.password": "پاس ورڈ", + "label.path": "Path", + "label.paths": "Paths", + "label.pixels": "Pixels", + "label.powered-by": "تقویت یافتہ بذریعہ {name}", + "label.previous": "Previous", + "label.previous-period": "Previous period", + "label.previous-year": "Previous year", + "label.profile": "پروفائل", + "label.properties": "Properties", + "label.property": "Property", + "label.queries": "Queries", + "label.query": "Query", + "label.query-parameters": "Query parameters", + "label.realtime": "براہ راست", + "label.referral": "Referral", + "label.referrer": "Referrer", + "label.referrers": "بھیجنے والے", + "label.refresh": "تازہ دم کریں", + "label.regenerate": "Regenerate", + "label.region": "Region", + "label.regions": "Regions", + "label.remaining": "Remaining", + "label.remove": "Remove", + "label.remove-member": "Remove member", + "label.reports": "Reports", + "label.required": "درکار ہے", + "label.reset": "دوبارہ ترتیب دیں", + "label.reset-website": "اعدادوشمار کو دوبارہ ترتیب دیں", + "label.retention": "Retention", + "label.retention-description": "Measure your website stickiness by tracking how often users return.", + "label.revenue": "Revenue", + "label.revenue-description": "Look into your revenue across time.", + "label.role": "Role", + "label.run-query": "Run query", + "label.save": "محفوظ کریں", + "label.screens": "Screens", + "label.search": "Search", + "label.select": "Select", + "label.select-date": "Select date", + "label.select-filter": "Select filter", + "label.select-role": "Select role", + "label.select-website": "Select website", + "label.session": "Session", + "label.session-data": "Session data", + "label.sessions": "Sessions", + "label.settings": "ترتیبات", + "label.share": "Share", + "label.share-url": "URL کا اشتراک کریں", + "label.single-day": "ایک دن", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "Start Step", + "label.steps": "Steps", + "label.sum": "Sum", + "label.tablet": "ٹیبلیٹ", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "Team", + "label.team-id": "Team ID", + "label.team-manager": "Team manager", + "label.team-member": "Team member", + "label.team-name": "Team name", + "label.team-owner": "Team owner", + "label.team-settings": "Team settings", + "label.team-view-only": "Team view only", + "label.team-websites": "Team websites", + "label.teams": "Teams", + "label.terms": "Terms", + "label.theme": "Theme", + "label.this-month": "اس مہینے", + "label.this-week": "اس ہفتے", + "label.this-year": "اس سال", + "label.timezone": "ٹائم زون", + "label.title": "Title", + "label.today": "آج", + "label.toggle-charts": "چارٹ تبدیل کریں", + "label.total": "Total", + "label.total-records": "Total records", + "label.tracking-code": "ٹریکنگ کوڈ", + "label.transactions": "Transactions", + "label.transfer": "Transfer", + "label.transfer-website": "Transfer website", + "label.true": "True", + "label.type": "Type", + "label.unique": "Unique", + "label.unique-visitors": "منفرد زائرین", + "label.uniqueCustomers": "Unique Customers", + "label.unknown": "نامعلوم", + "label.untitled": "Untitled", + "label.update": "Update", + "label.user": "User", + "label.username": "صارف نام", + "label.users": "Users", + "label.utm": "UTM", + "label.utm-description": "Track your campaigns through UTM parameters.", + "label.value": "Value", + "label.view": "View", + "label.view-details": "تفصیلات دیکھیں", + "label.view-only": "View only", + "label.views": "مناظر", + "label.views-per-visit": "Views per visit", + "label.visit-duration": "وزٹ کا اوسط وقت", + "label.visitors": "زائرین", + "label.visits": "Visits", + "label.website": "Website", + "label.website-id": "Website ID", + "label.websites": "ویب سائٹس", + "label.window": "Window", + "label.yesterday": "Yesterday", + "message.action-confirmation": "Type {confirmation} in the box below to confirm.", + "message.active-users": "{x} موجودہ {x, plural, one {زائر} other {زائرین}}", + "message.bad-request": "Bad request", + "message.collected-data": "Collected data", + "message.confirm-delete": "کیا آپ واقعی {target} کو حذف کرنا چاہتے ہیں؟", + "message.confirm-leave": "Are you sure you want to leave {target}?", + "message.confirm-remove": "Are you sure you want to remove {target}?", + "message.confirm-reset": "کیا آپ واقعی {target} کے اعدادوشمار کو دوبارہ ترتیب دینا چاہتے ہیں؟", + "message.delete-team-warning": "Deleting a team will also delete all team websites.", + "message.delete-website-warning": "تمام متعلقہ ڈیٹا بھی حذف کر دیا جائے گا۔", + "message.error": "کچھ غلط ہو گیا.", + "message.event-log": "{event} on {url}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "ترتیبات پر جائیں", + "message.incorrect-username-password": "غلط صارف نام/پاس ورڈ۔", + "message.invalid-domain": "غلط ڈومین", + "message.min-password-length": "Minimum length of {n} characters", + "message.new-version-available": "A new version of Umami {version} is available!", + "message.no-data-available": "مواد موجود نہیں ہے.", + "message.no-event-data": "No event data is available.", + "message.no-match-password": "پاس ورڈز مماثل نہیں ہیں", + "message.no-results-found": "No results were found.", + "message.no-team-websites": "This team does not have any websites.", + "message.no-teams": "You have not created any teams.", + "message.no-users": "There are no users.", + "message.no-websites-configured": "آپ کے پاس کوئی ویب سائٹ کنفیگر نہیں ہے۔", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "صفحہ نہیں ملا.", + "message.reset-website": "To reset this website, type {confirmation} in the box below to confirm.", + "message.reset-website-warning": "اس ویب سائٹ کے تمام اعدادوشمار کو حذف کر دیا جائے گا، لیکن آپ کا ٹریکنگ کوڈ برقرار رہے گا۔", + "message.saved": "کامیابی سے محفوظ ہو گیا۔", + "message.sever-error": "Server error", + "message.share-url": "یہ {target} کے لیے عوامی طور پر اشتراک کردہ URL ہے۔", + "message.team-already-member": "You are already a member of the team.", + "message.team-not-found": "Team not found.", + "message.team-websites-info": "Websites can be viewed by anyone on the team.", + "message.tracking-code": "ٹریکنگ کوڈ", + "message.transfer-team-website-to-user": "Transfer this website to your account?", + "message.transfer-user-website-to-team": "Select the team to transfer this website to.", + "message.transfer-website": "Transfer website ownership to your account or another team.", + "message.triggered-event": "Triggered event", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "User deleted.", + "message.viewed-page": "Viewed page", + "message.visitor-log": "{os} {device} پر {browser} کا استعمال کرتے ہوئے {country} سے آنے والا" +} diff --git a/src/lang/uz-UZ.json b/src/lang/uz-UZ.json new file mode 100644 index 0000000..cf58945 --- /dev/null +++ b/src/lang/uz-UZ.json @@ -0,0 +1,280 @@ +{ + "label.access-code": "Kirish kodi", + "label.actions": "Amallar", + "label.activity": "Faoliyat", + "label.add": "Qoʻshish", + "label.add-description": "Tavsif qoʻshish", + "label.add-member": "A'zo qoʻshish", + "label.add-step": "Qadam qoʻshish", + "label.add-website": "Veb-sayt qoʻshish", + "label.admin": "Administrator", + "label.after": "Keyin", + "label.all": "Barchasi", + "label.all-time": "Barcha vaqtlar", + "label.analytics": "Tahlil", + "label.average": "Oʻrtacha", + "label.back": "Orqaga", + "label.before": "Oldin", + "label.behavior": "Xulq-atvor", + "label.bounce-rate": "Chiqib ketish darajasi", + "label.breakdown": "Tahlil", + "label.browser": "Brauzer", + "label.browsers": "Brauzerlar", + "label.cancel": "Bekor qilish", + "label.change-password": "Parolni oʻzgartirish", + "label.cities": "Shaharlar", + "label.city": "Shahar", + "label.clear-all": "Barchasini tozalash", + "label.compare": "Taqqoslash", + "label.confirm": "Tasdiqlash", + "label.confirm-password": "Parolni tasdiqlash", + "label.contains": "Oʻz ichiga oladi", + "label.continue": "Davom etish", + "label.count": "Soni", + "label.countries": "Davlatlar", + "label.country": "Davlat", + "label.create": "Yaratish", + "label.create-report": "Hisobot yaratish", + "label.create-team": "Jamoa yaratish", + "label.create-user": "Foydalanuvchi yaratish", + "label.created": "Yaratilgan", + "label.created-by": "Kim tomonidan yaratilgan", + "label.current": "Joriy", + "label.current-password": "Joriy parol", + "label.custom-range": "Maxsus oraliq", + "label.dashboard": "Boshqaruv paneli", + "label.data": "Ma'lumotlar", + "label.date": "Sana", + "label.date-range": "Sana oraligʻi", + "label.day": "Kun", + "label.default-date-range": "Standart sana oraligʻi", + "label.delete": "Oʻchirish", + "label.delete-report": "Hisobotni oʻchirish", + "label.delete-team": "Jamoani oʻchirish", + "label.delete-user": "Foydalanuvchini oʻchirish", + "label.delete-website": "Veb-saytni oʻchirish", + "label.description": "Tavsif", + "label.desktop": "Ish stoli", + "label.details": "Batafsil ma'lumot", + "label.device": "Qurilma", + "label.devices": "Qurilmalar", + "label.dismiss": "Yopish", + "label.does-not-contain": "Oʻz ichiga olmaydi", + "label.domain": "Domen", + "label.dropoff": "Tashlab ketish", + "label.edit": "Tahrirlash", + "label.edit-dashboard": "Boshqaruv panelini tahrirlash", + "label.edit-member": "A'zoni tahrirlash", + "label.enable-share-url": "Ulashish URL'ini yoqish", + "label.end-step": "Yakuniy qadam", + "label.entry": "Kirish yoʻli", + "label.event": "Hodisa", + "label.event-data": "Hodisa ma'lumotlari", + "label.events": "Hodisalar", + "label.exit": "Chiqish yoʻli", + "label.false": "Yolgʻon", + "label.field": "Maydon", + "label.fields": "Maydonlar", + "label.filter": "Filtr", + "label.filter-combined": "Birlashtirilgan", + "label.filter-raw": "Xom", + "label.filters": "Filtrlar", + "label.first-seen": "Birinchi koʻrilgan", + "label.funnel": "Voronka", + "label.funnel-description": "Foydalanuvchilarning konversiya va tashlab ketish darajasini tushunish.", + "label.goal": "Maqsad", + "label.goals": "Maqsadlar", + "label.goals-description": "Sahifa koʻrishlari va hodisalar uchun maqsadlaringizni kuzatib boring.", + "label.greater-than": "Kattaroq", + "label.greater-than-equals": "Kattaroq yoki teng", + "label.host": "Xost", + "label.hosts": "Xostlar", + "label.insights": "Tushunchalar", + "label.insights-description": "Segmentlar va filtrlardan foydalanib ma'lumotlaringizga chuqurroq kiring.", + "label.is": "Teng", + "label.is-not": "Teng emas", + "label.is-not-set": "Oʻrnatilmagan", + "label.is-set": "Oʻrnatilgan", + "label.join": "Qoʻshilish", + "label.join-team": "Jamoaga qoʻshilish", + "label.journey": "Sayohat", + "label.journey-description": "Foydalanuvchilar veb-saytingizda qanday harakat qilishlarini tushunish.", + "label.language": "Til", + "label.languages": "Tillar", + "label.laptop": "Noutbuk", + "label.last-days": "Oxirgi {x} kun", + "label.last-hours": "Oxirgi {x} soat", + "label.last-months": "Oxirgi {x} oy", + "label.last-seen": "Oxirgi koʻrilgan", + "label.leave": "Tark etish", + "label.leave-team": "Jamoani tark etish", + "label.less-than": "Kichikroq", + "label.less-than-equals": "Kichikroq yoki teng", + "label.login": "Kirish", + "label.logout": "Chiqish", + "label.manage": "Boshqarish", + "label.manager": "Menejer", + "label.max": "Maksimal", + "label.member": "A'zo", + "label.members": "A'zolar", + "label.min": "Minimal", + "label.mobile": "Mobil", + "label.more": "Koʻproq", + "label.my-account": "Mening hisobim", + "label.my-websites": "Mening veb-saytlarim", + "label.name": "Ism", + "label.new-password": "Yangi parol", + "label.none": "Hech biri", + "label.number-of-records": "{x} yozuv", + "label.ok": "OK", + "label.os": "OT (Operatsion tizim)", + "label.overview": "Umumiy koʻrinish", + "label.owner": "Egasi", + "label.page-of": "Sahifa {current} dan {total}", + "label.page-views": "Sahifa koʻrishlari", + "label.pageTitle": "Sahifa sarlavhasi", + "label.pages": "Sahifalar", + "label.password": "Parol", + "label.path": "Yoʻl", + "label.paths": "Yoʻllar", + "label.powered-by": "{name} tomonidan quvvatlanadi", + "label.previous": "Oldingi", + "label.previous-period": "Oldingi davr", + "label.previous-year": "Oldingi yil", + "label.profile": "Profil", + "label.properties": "Xususiyatlar", + "label.property": "Xususiyat", + "label.queries": "Soʻrovlar", + "label.query": "Soʻrov", + "label.query-parameters": "Soʻrov parametrlari", + "label.realtime": "Haqiqiy vaqt", + "label.referrer": "Tavsiya etuvchi", + "label.referrers": "Tavsiya etuvchilar", + "label.refresh": "Yangilash", + "label.regenerate": "Qayta yaratish", + "label.region": "Viloyat/Mintaqa", + "label.regions": "Viloyatlar/Mintaqalar", + "label.remove": "Olib tashlash", + "label.remove-member": "A'zoni olib tashlash", + "label.reports": "Hisobotlar", + "label.required": "Majburiy", + "label.reset": "Qayta tiklash", + "label.reset-website": "Veb-saytni qayta tiklash", + "label.retention": "Saqlanish", + "label.retention-description": "Foydalanuvchilarning qaytish chastotasini kuzatib, veb-saytingizning jozibadorligini oʻlchang.", + "label.revenue": "Daromad", + "label.revenue-description": "Vaqt oʻtishi bilan daromadingizni tekshiring.", + "label.revenue-property": "Daromad xususiyati", + "label.role": "Rol", + "label.run-query": "Soʻrovni ishga tushirish", + "label.save": "Saqlash", + "label.screens": "Ekranlar", + "label.search": "Qidiruv", + "label.select": "Tanlash", + "label.select-date": "Sanani tanlash", + "label.select-role": "Rolni tanlash", + "label.select-website": "Veb-saytni tanlash", + "label.session": "Sessiya", + "label.sessions": "Sessiyalar", + "label.settings": "Sozlamalar", + "label.share-url": "Ulashish URL'i", + "label.single-day": "Bir kun", + "label.start-step": "Boshlanish qadami", + "label.steps": "Qadamlar", + "label.sum": "Yigʻindi", + "label.tablet": "Planshet", + "label.team": "Jamoa", + "label.team-id": "Jamoa ID'si", + "label.team-manager": "Jamoa menejeri", + "label.team-member": "Jamoa a'zosi", + "label.team-name": "Jamoa nomi", + "label.team-owner": "Jamoa egasi", + "label.team-view-only": "Jamoa faqat koʻrish", + "label.team-websites": "Jamoa veb-saytlari", + "label.teams": "Jamoalar", + "label.theme": "Mavzu", + "label.this-month": "Shu oy", + "label.this-week": "Shu hafta", + "label.this-year": "Shu yil", + "label.timezone": "Vaqt zonasi", + "label.title": "Sarlavha", + "label.today": "Bugun", + "label.toggle-charts": "Grafiklarni almashtirish", + "label.total": "Jami", + "label.total-records": "Jami yozuvlar", + "label.tracking-code": "Kuzatuv kodi", + "label.transactions": "Tranzaksiyalar", + "label.transfer": "Oʻtkazish", + "label.transfer-website": "Veb-saytni oʻtkazish", + "label.true": "Rost", + "label.type": "Tur", + "label.unique": "Noyob", + "label.unique-visitors": "Noyob tashrif buyuruvchilar", + "label.uniqueCustomers": "Noyob mijozlar", + "label.unknown": "Noma'lum", + "label.untitled": "Sarlavhasiz", + "label.update": "Yangilash", + "label.url": "URL", + "label.urls": "URL'lar", + "label.user": "Foydalanuvchi", + "label.user-property": "Foydalanuvchi xususiyati", + "label.username": "Foydalanuvchi nomi", + "label.users": "Foydalanuvchilar", + "label.utm": "UTM", + "label.utm-description": "UTM parametrlari orqali kampaniyalaringizni kuzatib boring.", + "label.value": "Qiymat", + "label.view": "Koʻrish", + "label.view-details": "Batafsil koʻrish", + "label.view-only": "Faqat koʻrish", + "label.views": "Koʻrishlar", + "label.views-per-visit": "Tashrifga koʻrishlar soni", + "label.visit-duration": "Tashrif davomiyligi", + "label.visitors": "Tashrif buyuruvchilar", + "label.visits": "Tashriflar", + "label.website": "Veb-sayt", + "label.website-id": "Veb-sayt ID'si", + "label.websites": "Veb-saytlar", + "label.window": "Oyna", + "label.yesterday": "Kecha", + "message.action-confirmation": "Tasdiqlash uchun pastdagi qutiga **{confirmation}** yozing.", + "message.active-users": "{x} joriy {x, plural, one {tashrif buyuruvchi} other {tashrif buyuruvchilar}}", + "message.collected-data": "Yigʻilgan ma'lumotlar", + "message.confirm-delete": "**{target}** ni oʻchirmoqchi ekanligingizga ishonchingiz komilmi?", + "message.confirm-leave": "**{target}** ni tark etmoqchi ekanligingizga ishonchingiz komilmi?", + "message.confirm-remove": "**{target}** ni olib tashlamoqchi ekanligingizga ishonchingiz komilmi?", + "message.confirm-reset": "**{target}** ni qayta tiklamoqchi ekanligingizga ishonchingiz komilmi?", + "message.delete-team-warning": "Jamoani oʻchirish, shuningdek, barcha jamoa veb-saytlarini ham oʻchiradi.", + "message.delete-website-warning": "Barcha veb-sayt ma'lumotlari oʻchiriladi.", + "message.error": "Nimadir xato ketdi.", + "message.event-log": "**{url}** da **{event}** hodisasi", + "message.go-to-settings": "Sozlamalarga oʻtish", + "message.incorrect-username-password": "Notoʻgʻri foydalanuvchi nomi va/yoki parol.", + "message.invalid-domain": "Notoʻgʻri domen. http/https qoʻshmang.", + "message.min-password-length": "Minimal uzunligi {n} belgidan", + "message.new-version-available": "Umami'ning yangi **{version}** versiyasi mavjud!", + "message.no-data-available": "Ma'lumotlar mavjud emas.", + "message.no-event-data": "Hodisa ma'lumotlari mavjud emas.", + "message.no-match-password": "Parollar mos kelmadi.", + "message.no-results-found": "Hech qanday natija topilmadi.", + "message.no-team-websites": "Bu jamoada hech qanday veb-sayt yoʻq.", + "message.no-teams": "Siz hech qanday jamoa yaratmagansiz.", + "message.no-users": "Hech qanday foydalanuvchi yoʻq.", + "message.no-websites-configured": "Sizda hech qanday veb-sayt sozlanmagan.", + "message.page-not-found": "Sahifa topilmadi", + "message.reset-website": "Bu veb-saytni qayta tiklash uchun tasdiqlash uchun pastdagi qutiga **{confirmation}** yozing.", + "message.reset-website-warning": "Bu veb-sayt uchun barcha statistik ma'lumotlar oʻchiriladi, lekin sozlamalaringiz saqlanib qoladi.", + "message.saved": "Saqlandi.", + "message.share-url": "Sizning veb-sayt statistikalaringiz quyidagi URL'da ochiqdir:", + "message.team-already-member": "Siz allaqachon jamoa a'zosisiz.", + "message.team-not-found": "Jamoa topilmadi.", + "message.team-websites-info": "Veb-saytlarni jamoaning har bir a'zosi koʻrishi mumkin.", + "message.tracking-code": "Bu veb-sayt uchun statistikani kuzatish uchun quyidagi kodni HTML'ingizdagi **<head>...</head>** qismiga joylashtiring.", + "message.transfer-team-website-to-user": "Bu veb-saytni oʻz hisobingizga oʻtkazasizmi?", + "message.transfer-user-website-to-team": "Bu veb-saytni oʻtkazish uchun jamoani tanlang.", + "message.transfer-website": "Veb-sayt egaligini oʻz hisobingizga yoki boshqa jamoaga oʻtkazish.", + "message.triggered-event": "Hodisa ishga tushirildi", + "message.user-deleted": "Foydalanuvchi oʻchirildi.", + "message.viewed-page": "Sahifa koʻrildi", + "message.visitor-log": "{os} {device} da {browser} dan foydalanayotgan {country} dan tashrif buyuruvchi", + "message.visitors-dropped-off": "Tashrif buyuruvchilar tashlab ketishdi" +} diff --git a/src/lang/vi-VN.json b/src/lang/vi-VN.json new file mode 100644 index 0000000..fc0a8c1 --- /dev/null +++ b/src/lang/vi-VN.json @@ -0,0 +1,280 @@ +{ + "label.access-code": "Mã truy cập", + "label.actions": "Hành động", + "label.activity": "Nhật ký hoạt động", + "label.add": "Thêm", + "label.add-description": "Thêm mô tả", + "label.add-member": "Thêm thành viên", + "label.add-step": "Thêm bước", + "label.add-website": "Thêm website", + "label.admin": "Quản trị", + "label.after": "Sau đó", + "label.all": "Tất cả", + "label.all-time": "Toàn thời gian", + "label.analytics": "Phân tích", + "label.average": "Trung bình", + "label.back": "Quay lại", + "label.before": "Trước đó", + "label.behavior": "Hành vi", + "label.bounce-rate": "Tỷ lệ thoát trang", + "label.breakdown": "Phân tích chi tiết", + "label.browser": "Trình duyệt", + "label.browsers": "Các trình duyệt", + "label.cancel": "Hủy bỏ", + "label.change-password": "Đổi mật khẩu", + "label.cities": "Các thành phố", + "label.city": "Thành phố", + "label.clear-all": "Xóa tất cả", + "label.compare": "So sánh", + "label.confirm": "Xác nhận", + "label.confirm-password": "Xác nhận mật khẩu", + "label.contains": "Chứa", + "label.continue": "Tiếp tục", + "label.count": "Số lượng", + "label.countries": "Các quốc gia", + "label.country": "Quốc gia", + "label.create": "Tạo", + "label.create-report": "Tạo báo cáo", + "label.create-team": "Tạo nhóm", + "label.create-user": "Tạo người dùng", + "label.created": "Đã tạo", + "label.created-by": "Được tạo bởi", + "label.current": "Hiện tại", + "label.current-password": "Mật khẩu hiện tại", + "label.custom-range": "Phạm vi tùy chỉnh", + "label.dashboard": "Bảng điều khiển", + "label.data": "Dữ liệu", + "label.date": "Ngày", + "label.date-range": "Phạm vi ngày", + "label.day": "Ngày", + "label.default-date-range": "Khoảng thời gian mặc định", + "label.delete": "Xóa", + "label.delete-report": "Xóa báo cáo", + "label.delete-team": "Xóa nhóm", + "label.delete-user": "Xóa người dùng", + "label.delete-website": "Xóa website", + "label.description": "Mô tả", + "label.desktop": "Máy tính để bàn", + "label.details": "Chi tiết", + "label.device": "Thiết bị", + "label.devices": "Các thiết bị", + "label.dismiss": "Bỏ qua", + "label.does-not-contain": "Không chứa", + "label.domain": "Tên miền", + "label.dropoff": "Tỷ lệ bỏ qua", + "label.edit": "Chỉnh sửa", + "label.edit-dashboard": "Chỉnh sửa bảng điều khiển", + "label.edit-member": "Chỉnh sửa thành viên", + "label.enable-share-url": "Bật chia sẻ URL", + "label.end-step": "Bước kết thúc", + "label.entry": "URL truy cập", + "label.event": "Sự kiện", + "label.event-data": "Dữ liệu sự kiện", + "label.events": "Các sự kiện", + "label.exit": "URL thoát", + "label.false": "Sai", + "label.field": "Trường", + "label.fields": "Các trường", + "label.filter": "Lọc", + "label.filter-combined": "Kết hợp lọc", + "label.filter-raw": "Lọc thô", + "label.filters": "Bộ lọc", + "label.first-seen": "Lần đầu tiên nhìn thấy", + "label.funnel": "Phễu", + "label.funnel-description": "Tìm hiểu tỷ lệ chuyển đổi và bỏ qua của người dùng.", + "label.goal": "Mục tiêu", + "label.goals": "Các mục tiêu", + "label.goals-description": "Theo dõi các mục tiêu của bạn cho lượt xem trang và sự kiện.", + "label.greater-than": "Lớn hơn", + "label.greater-than-equals": "Lớn hơn hoặc bằng", + "label.host": "Máy chủ", + "label.hosts": "Các máy chủ", + "label.insights": "Thông tin chi tiết", + "label.insights-description": "Tìm hiểu sâu hơn về dữ liệu của bạn bằng cách sử dụng phân đoạn và bộ lọc.", + "label.is": "Là", + "label.is-not": "Không phải là", + "label.is-not-set": "Chưa được đặt", + "label.is-set": "Đã đặt", + "label.join": "Tham gia", + "label.join-team": "Tham gia nhóm", + "label.journey": "Hành trình", + "label.journey-description": "Hiểu cách người dùng điều hướng qua website của bạn.", + "label.language": "Ngôn ngữ", + "label.languages": "Các ngôn ngữ", + "label.laptop": "Máy tính xách tay", + "label.last-days": "{x} ngày gần nhất", + "label.last-hours": "{x} giờ gần nhất", + "label.last-months": "{x} tháng gần nhất", + "label.last-seen": "Lần cuối cùng nhìn thấy", + "label.leave": "Rời khỏi", + "label.leave-team": "Rời nhóm", + "label.less-than": "Nhỏ hơn", + "label.less-than-equals": "Nhỏ hơn hoặc bằng", + "label.login": "Đăng nhập", + "label.logout": "Đăng xuất", + "label.manage": "Quản lý", + "label.manager": "Quản lý", + "label.max": "Tối đa", + "label.member": "Thành viên", + "label.members": "Các thành viên", + "label.min": "Tối thiểu", + "label.mobile": "Di động", + "label.more": "Thêm", + "label.my-account": "Tài khoản của tôi", + "label.my-websites": "Các website của tôi", + "label.name": "Tên", + "label.new-password": "Mật khẩu mới", + "label.none": "Không", + "label.number-of-records": "{x} {x, plural, one {bản ghi} other {bản ghi}}", + "label.ok": "OK", + "label.os": "Hệ điều hành", + "label.overview": "Tổng quan", + "label.owner": "Chủ sở hữu", + "label.page-of": "Trang {current} trên {total}", + "label.page-views": "Lượt xem trang", + "label.pageTitle": "Tiêu đề trang", + "label.pages": "Các trang", + "label.password": "Mật khẩu", + "label.path": "Đường dẫn", + "label.paths": "Các đường dẫn", + "label.powered-by": "Được cung cấp bởi {name}", + "label.previous": "Trước", + "label.previous-period": "Kỳ trước", + "label.previous-year": "Năm trước", + "label.profile": "Hồ sơ", + "label.properties": "Thuộc tính", + "label.property": "Thuộc tính", + "label.queries": "Truy vấn", + "label.query": "Truy vấn", + "label.query-parameters": "Tham số truy vấn", + "label.realtime": "Thời gian thực", + "label.referrer": "Nguồn giới thiệu", + "label.referrers": "Các nguồn giới thiệu", + "label.refresh": "Làm mới", + "label.regenerate": "Tạo lại", + "label.region": "Vùng", + "label.regions": "Các vùng", + "label.remove": "Xóa", + "label.remove-member": "Xóa thành viên", + "label.reports": "Báo cáo", + "label.required": "Yêu cầu", + "label.reset": "Đặt lại", + "label.reset-website": "Đặt lại thống kê website", + "label.retention": "Tỷ lệ giữ chân", + "label.retention-description": "Đo lường mức độ gắn bó của website bằng cách theo dõi tần suất người dùng quay lại.", + "label.revenue": "Doanh thu", + "label.revenue-description": "Xem xét doanh thu của bạn theo thời gian.", + "label.revenue-property": "Thuộc tính doanh thu", + "label.role": "Vai trò", + "label.run-query": "Chạy truy vấn", + "label.save": "Lưu", + "label.screens": "Màn hình", + "label.search": "Tìm kiếm", + "label.select": "Chọn", + "label.select-date": "Chọn ngày", + "label.select-role": "Chọn vai trò", + "label.select-website": "Chọn website", + "label.session": "Phiên", + "label.sessions": "Các phiên", + "label.settings": "Cài đặt", + "label.share-url": "Chia sẻ URL", + "label.single-day": "Một ngày", + "label.start-step": "Bước bắt đầu", + "label.steps": "Các bước", + "label.sum": "Tổng", + "label.tablet": "Máy tính bảng", + "label.team": "Nhóm", + "label.team-id": "ID nhóm", + "label.team-manager": "Quản lý nhóm", + "label.team-member": "Thành viên nhóm", + "label.team-name": "Tên nhóm", + "label.team-owner": "Chủ sở hữu nhóm", + "label.team-view-only": "Chỉ xem nhóm", + "label.team-websites": "Các website của nhóm", + "label.teams": "Các nhóm", + "label.theme": "Chủ đề", + "label.this-month": "Tháng này", + "label.this-week": "Tuần này", + "label.this-year": "Năm nay", + "label.timezone": "Múi giờ", + "label.title": "Tiêu đề", + "label.today": "Hôm nay", + "label.toggle-charts": "Bật/tắt biểu đồ", + "label.total": "Tổng", + "label.total-records": "Tổng số bản ghi", + "label.tracking-code": "Mã theo dõi", + "label.transactions": "Giao dịch", + "label.transfer": "Chuyển giao", + "label.transfer-website": "Chuyển giao website", + "label.true": "Đúng", + "label.type": "Loại", + "label.unique": "Duy nhất", + "label.unique-visitors": "Khách truy cập duy nhất", + "label.uniqueCustomers": "Khách hàng duy nhất", + "label.unknown": "Không rõ", + "label.untitled": "Không có tiêu đề", + "label.update": "Cập nhật", + "label.url": "URL", + "label.urls": "Các URL", + "label.user": "Người dùng", + "label.user-property": "Thuộc tính người dùng", + "label.username": "Tên đăng nhập", + "label.users": "Người dùng", + "label.utm": "UTM", + "label.utm-description": "Theo dõi các chiến dịch của bạn thông qua các tham số UTM.", + "label.value": "Giá trị", + "label.view": "Xem", + "label.view-details": "Xem chi tiết", + "label.view-only": "Chỉ xem", + "label.views": "Lượt xem", + "label.views-per-visit": "Lượt xem trên mỗi lượt truy cập", + "label.visit-duration": "Thời lượng truy cập", + "label.visitors": "Khách truy cập", + "label.visits": "Lượt truy cập", + "label.website": "Website", + "label.website-id": "ID website", + "label.websites": "Các website", + "label.window": "Cửa sổ", + "label.yesterday": "Hôm qua", + "message.action-confirmation": "Nhập {confirmation} vào ô bên dưới để xác nhận.", + "message.active-users": "{x} {x, plural, one {người dùng} other {người dùng}} đang hoạt động", + "message.collected-data": "Dữ liệu đã thu thập", + "message.confirm-delete": "Bạn có chắc chắn muốn xóa {target}?", + "message.confirm-leave": "Bạn có chắc chắn muốn rời {target}?", + "message.confirm-remove": "Bạn có chắc chắn muốn xóa {target}?", + "message.confirm-reset": "Bạn có chắc chắn muốn đặt lại thống kê {target}?", + "message.delete-team-warning": "Việc xóa một nhóm cũng sẽ xóa tất cả các website của nhóm.", + "message.delete-website-warning": "Tất cả dữ liệu liên quan cũng sẽ bị xóa.", + "message.error": "Đã xảy ra lỗi.", + "message.event-log": "{event} trên {url}", + "message.go-to-settings": "Chuyển đến cài đặt", + "message.incorrect-username-password": "Sai tên đăng nhập/mật khẩu.", + "message.invalid-domain": "Tên miền không hợp lệ", + "message.min-password-length": "Độ dài tối thiểu {n} ký tự", + "message.new-version-available": "Có phiên bản mới của Umami {version}!", + "message.no-data-available": "Không có dữ liệu.", + "message.no-event-data": "Không có dữ liệu sự kiện.", + "message.no-match-password": "Mật khẩu không khớp", + "message.no-results-found": "Không tìm thấy kết quả nào.", + "message.no-team-websites": "Nhóm này không có bất kỳ website nào.", + "message.no-teams": "Bạn chưa tạo nhóm nào.", + "message.no-users": "Không có người dùng nào.", + "message.no-websites-configured": "Bạn chưa cấu hình bất kỳ website nào.", + "message.page-not-found": "Không tìm thấy trang.", + "message.reset-website": "Để đặt lại website này, nhập {confirmation} vào ô bên dưới để xác nhận.", + "message.reset-website-warning": "Tất cả số liệu thống kê của website này sẽ bị xóa, nhưng mã theo dõi sẽ vẫn giữ nguyên.", + "message.saved": "Đã lưu thành công.", + "message.share-url": "Đây là đường dẫn URL cho {target}.", + "message.team-already-member": "Bạn đã là thành viên của nhóm.", + "message.team-not-found": "Không tìm thấy nhóm.", + "message.team-websites-info": "Bất kỳ ai trong nhóm đều có thể xem các website.", + "message.tracking-code": "Mã theo dõi", + "message.transfer-team-website-to-user": "Chuyển website này sang tài khoản của bạn?", + "message.transfer-user-website-to-team": "Chọn nhóm để chuyển website này đến.", + "message.transfer-website": "Chuyển quyền sở hữu website sang tài khoản của bạn hoặc một nhóm khác.", + "message.triggered-event": "Sự kiện được kích hoạt", + "message.user-deleted": "Người dùng đã bị xóa.", + "message.viewed-page": "Đã xem trang", + "message.visitor-log": "Khách từ {country} đang sử dụng {browser} trên {os} {device}", + "message.visitors-dropped-off": "Khách truy cập đã rời đi" +} diff --git a/src/lang/zh-CN.json b/src/lang/zh-CN.json new file mode 100644 index 0000000..c6f01dd --- /dev/null +++ b/src/lang/zh-CN.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "访问代码", + "label.actions": "用户行为", + "label.activity": "活动日志", + "label.add": "添加", + "label.add-board": "添加看板", + "label.add-description": "添加描述", + "label.add-member": "添加成员", + "label.add-step": "添加步骤", + "label.add-website": "添加网站", + "label.admin": "管理员", + "label.affiliate": "联盟", + "label.after": "之后", + "label.all": "所有", + "label.all-time": "所有时间段", + "label.analytics": "分析", + "label.apply": "应用", + "label.attribution": "归因", + "label.attribution-description": "查看用户如何与您的营销互动,以及是什么促成了转化。", + "label.average": "平均", + "label.back": "返回", + "label.before": "之前", + "label.behavior": "行为", + "label.boards": "看板", + "label.bounce-rate": "跳出率", + "label.breakdown": "故障", + "label.browser": "浏览器", + "label.browsers": "浏览器", + "label.campaigns": "活动", + "label.cancel": "取消", + "label.change-password": "修改密码", + "label.channels": "渠道", + "label.cities": "市/县", + "label.city": "市/县", + "label.clear-all": "清除全部", + "label.cohort": "队列", + "label.compare": "比较", + "label.compare-dates": "比较日期", + "label.confirm": "确认", + "label.confirm-password": "确认密码", + "label.contains": "包含", + "label.content": "内容", + "label.continue": "继续", + "label.conversion": "转化", + "label.conversion-rate": "转化率", + "label.conversion-step": "转化步骤", + "label.count": "统计", + "label.countries": "国家/地区", + "label.country": "国家/地区", + "label.create": "创建", + "label.create-report": "创建报告", + "label.create-team": "创建团队", + "label.create-user": "创建用户", + "label.created": "已创建", + "label.created-by": "创建者", + "label.currency": "货币", + "label.current": "当前", + "label.current-password": "当前密码", + "label.custom-range": "自定义时间段", + "label.dashboard": "仪表盘", + "label.data": "统计数据", + "label.date": "日期", + "label.date-range": "时间段", + "label.day": "日", + "label.default-date-range": "默认时间段", + "label.delete": "删除", + "label.delete-report": "删除报告", + "label.delete-team": "删除团队", + "label.delete-user": "删除用户", + "label.delete-website": "删除网站", + "label.description": "描述", + "label.desktop": "台式机", + "label.details": "详细信息", + "label.device": "设备", + "label.devices": "设备", + "label.direct": "直接", + "label.dismiss": "关闭", + "label.distinct-id": "唯一ID", + "label.does-not-contain": "不包含", + "label.does-not-include": "不包括", + "label.doest-not-exist": "不存在", + "label.domain": "域名", + "label.dropoff": "丢弃", + "label.edit": "编辑", + "label.edit-dashboard": "编辑仪表盘", + "label.edit-member": "编辑成员", + "label.email": "Email", + "label.enable-share-url": "启用共享链接", + "label.end-step": "结束步骤", + "label.entry": "入口 URL", + "label.event": "事件", + "label.event-data": "事件数据", + "label.event-name": "事件名称", + "label.events": "行为类别", + "label.exists": "存在", + "label.exit": "退出 URL", + "label.false": "否", + "label.field": "字段", + "label.fields": "字段", + "label.filter": "筛选器", + "label.filter-combined": "合并", + "label.filter-raw": "原始", + "label.filters": "筛选", + "label.first-click": "首次点击", + "label.first-seen": "首次出现", + "label.funnel": "分析", + "label.funnel-description": "了解用户的转化率和跳出率。", + "label.funnels": "漏斗", + "label.goal": "目标", + "label.goals": "目标", + "label.goals-description": "跟踪页面浏览量和事件的目标。", + "label.greater-than": "大于", + "label.greater-than-equals": "大于或等于", + "label.grouped": "分组", + "label.hostname": "主机名", + "label.includes": "包括", + "label.insight": "洞察", + "label.insights": "见解", + "label.insights-description": "通过使用筛选器和划分时间段来更深入地研究数据。", + "label.is": "等于", + "label.is-false": "否", + "label.is-not": "不等于", + "label.is-not-set": "未设置", + "label.is-set": "已设置", + "label.is-true": "是", + "label.join": "加入", + "label.join-team": "加入团队", + "label.journey": "用户浏览轨迹", + "label.journey-description": "了解用户如何浏览网站。", + "label.journeys": "用户路径", + "label.language": "语言", + "label.languages": "语言", + "label.laptop": "笔记本", + "label.last-click": "最后点击", + "label.last-days": "最近 {x} 天", + "label.last-hours": "最近 {x} 小时", + "label.last-months": "最近 {x} 个月", + "label.last-seen": "最后出现", + "label.leave": "离开", + "label.leave-team": "离开团队", + "label.less-than": "少于", + "label.less-than-equals": "少于等于", + "label.links": "链接", + "label.login": "登录", + "label.logout": "退出", + "label.manage": "管理", + "label.manager": "管理者", + "label.max": "最大", + "label.maximize": "展开", + "label.medium": "中等", + "label.member": "成员", + "label.members": "成员", + "label.min": "最小", + "label.mobile": "手机", + "label.model": "模型", + "label.more": "更多", + "label.my-account": "我的账户", + "label.my-websites": "我的网站", + "label.name": "名字", + "label.new-password": "新密码", + "label.none": "无", + "label.number-of-records": "{x} {x, plural, one {record} other {records}}", + "label.ok": "好的", + "label.online": "Online", + "label.organic-search": "自然搜索", + "label.organic-shopping": "自然购物", + "label.organic-social": "自然社交", + "label.organic-video": "自然视频", + "label.os": "操作系统", + "label.other": "其他", + "label.overview": "概览", + "label.owner": "所有者", + "label.page": "页面", + "label.page-of": "总 {total} 中的第 {current} 页", + "label.page-views": "页面浏览量", + "label.pageTitle": "标题", + "label.pages": "网页", + "label.paid-ads": "付费广告", + "label.paid-search": "付费搜索", + "label.paid-shopping": "付费购物", + "label.paid-social": "付费社交", + "label.paid-video": "付费视频", + "label.password": "密码", + "label.path": "路径", + "label.paths": "路径", + "label.pixels": "像素", + "label.powered-by": "由 {name} 提供支持", + "label.previous": "先前", + "label.previous-period": "上一时期", + "label.previous-year": "上一年", + "label.profile": "个人资料", + "label.properties": "属性", + "label.property": "属性", + "label.queries": "查询", + "label.query": "查询", + "label.query-parameters": "查询参数", + "label.realtime": "实时", + "label.referral": "Referral", + "label.referrer": "来源", + "label.referrers": "来源域名", + "label.refresh": "刷新", + "label.regenerate": "重新生成", + "label.region": "州/省", + "label.regions": "州/省", + "label.remaining": "剩余", + "label.remove": "移除", + "label.remove-member": "移除成员", + "label.reports": "报告", + "label.required": "必填", + "label.reset": "重置", + "label.reset-website": "重置统计数据", + "label.retention": "保留", + "label.retention-description": "通过追踪用户回访频率来衡量您网站的用户粘性。", + "label.revenue": "收入", + "label.revenue-description": "查看随时间变化的收入数据。", + "label.role": "角色", + "label.run-query": "查询", + "label.save": "保存", + "label.screens": "屏幕尺寸", + "label.search": "搜索", + "label.select": "选择", + "label.select-date": "选择日期", + "label.select-filter": "选择筛选器", + "label.select-role": "选择角色", + "label.select-website": "选择网站", + "label.session": "会话", + "label.session-data": "会话数据", + "label.sessions": "会话", + "label.settings": "设置", + "label.share": "分享", + "label.share-url": "共享链接", + "label.single-day": "单日", + "label.sms": "SMS", + "label.sources": "来源", + "label.start-step": "开始步骤", + "label.steps": "步骤", + "label.sum": "总和", + "label.tablet": "平板", + "label.tag": "标签", + "label.tags": "标签", + "label.team": "团队", + "label.team-id": "团队 ID", + "label.team-manager": "团队管理员", + "label.team-member": "团队成员", + "label.team-name": "团队名称", + "label.team-owner": "团队所有者", + "label.team-settings": "团队设置", + "label.team-view-only": "仅团队视图", + "label.team-websites": "团队网站", + "label.teams": "团队", + "label.terms": "条款", + "label.theme": "主题", + "label.this-month": "本月", + "label.this-week": "本周", + "label.this-year": "今年", + "label.timezone": "时区", + "label.title": "标题", + "label.today": "今天", + "label.toggle-charts": "切换图表", + "label.total": "总数", + "label.total-records": "总记录数", + "label.tracking-code": "跟踪代码", + "label.transactions": "交易", + "label.transfer": "转移", + "label.transfer-website": "转移网站", + "label.true": "是", + "label.type": "类型", + "label.unique": "独立", + "label.unique-visitors": "独立访客", + "label.uniqueCustomers": "独特客户", + "label.unknown": "未知", + "label.untitled": "未命名", + "label.update": "更新", + "label.user": "用户", + "label.username": "用户名", + "label.users": "用户", + "label.utm": "UTM", + "label.utm-description": "通过 UTM 参数追踪您的广告活动。", + "label.value": "值", + "label.view": "查看", + "label.view-details": "查看更多", + "label.view-only": "仅浏览", + "label.views": "浏览量", + "label.views-per-visit": "每次访问的浏览量", + "label.visit-duration": "平均访问时长", + "label.visitors": "访客", + "label.visits": "访问次数", + "label.website": "网站", + "label.website-id": "网站 ID", + "label.websites": "网站", + "label.window": "窗口", + "label.yesterday": "昨天", + "message.action-confirmation": "请在下方输入框中输入 {confirmation} 以确认操作。", + "message.active-users": "当前在线 {x} 位访客", + "message.bad-request": "Bad request", + "message.collected-data": "已收集的数据", + "message.confirm-delete": "你确定要删除 {target} 吗?", + "message.confirm-leave": "你确定要离开 {target} 吗?", + "message.confirm-remove": "您确定要移除 {target} ?", + "message.confirm-reset": "您确定要重置 {target} 的数据吗?", + "message.delete-team-warning": "删除团队也会删除所有团队网站。", + "message.delete-website-warning": "所有相关数据将会被删除。", + "message.error": "发生错误。", + "message.event-log": "{url} 上的 {event}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "去设置", + "message.incorrect-username-password": "用户名或密码不正确。", + "message.invalid-domain": "无效域名", + "message.min-password-length": "密码最短长度为 {n} 个字符", + "message.new-version-available": "Umami 新版本 {version} 已发布!", + "message.no-data-available": "暂无数据。", + "message.no-event-data": "无可用事件。", + "message.no-match-password": "密码不一致", + "message.no-results-found": "未找到结果。", + "message.no-team-websites": "该团队暂无网站。", + "message.no-teams": "您尚未创建任何团队。", + "message.no-users": "暂无用户。", + "message.no-websites-configured": "你还没有设置任何网站。", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "页面未找到。", + "message.reset-website": "如确定要重置该网站,请在下面输入 {confirmation} 以确认。", + "message.reset-website-warning": "此网站的所有统计数据将被删除,但您的跟踪代码将保持不变。", + "message.saved": "保存成功。", + "message.sever-error": "Server error", + "message.share-url": "这是 {target} 的共享链接。", + "message.team-already-member": "你已是该团队的成员。", + "message.team-not-found": "未找到团队。", + "message.team-websites-info": "团队成员均可查看网站数据。", + "message.tracking-code": "跟踪代码", + "message.transfer-team-website-to-user": "将此网站转移到您的账户?", + "message.transfer-user-website-to-team": "选择要转移此网站的团队。", + "message.transfer-website": "将网站所有权转移到您的账户或其他团队。", + "message.triggered-event": "触发事件", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "用户已删除。", + "message.viewed-page": "已浏览页面", + "message.visitor-log": "来自 {country} 的访客在搭载 {os} 的 {device} 上使用 {browser} 浏览器进行访问。" +} diff --git a/src/lang/zh-TW.json b/src/lang/zh-TW.json new file mode 100644 index 0000000..030d11d --- /dev/null +++ b/src/lang/zh-TW.json @@ -0,0 +1,339 @@ +{ + "label.access-code": "存取碼", + "label.actions": "行為", + "label.activity": "活動紀錄", + "label.add": "新增", + "label.add-board": "新增看板", + "label.add-description": "新增描述", + "label.add-member": "新增成員", + "label.add-step": "新增步驟", + "label.add-website": "新增網站", + "label.admin": "管理員", + "label.affiliate": "聯盟", + "label.after": "之後", + "label.all": "全部", + "label.all-time": "所有時間", + "label.analytics": "分析", + "label.apply": "套用", + "label.attribution": "歸因", + "label.attribution-description": "查看使用者如何與您的行銷互動,以及什麼促成了轉換。", + "label.average": "平均", + "label.back": "返回", + "label.before": "之前", + "label.behavior": "行為", + "label.boards": "看板", + "label.bounce-rate": "跳出率", + "label.breakdown": "細項分析", + "label.browser": "瀏覽器", + "label.browsers": "瀏覽器", + "label.campaigns": "活動", + "label.cancel": "取消", + "label.change-password": "更改密碼", + "label.channels": "Channels", + "label.cities": "城市", + "label.city": "城市", + "label.clear-all": "全部清除", + "label.cohort": "群組", + "label.compare": "比較", + "label.compare-dates": "比較日期", + "label.confirm": "確認", + "label.confirm-password": "確認密碼", + "label.contains": "包含", + "label.content": "內容", + "label.continue": "繼續", + "label.conversion": "轉換", + "label.conversion-rate": "轉換率", + "label.conversion-step": "轉換步驟", + "label.count": "數量", + "label.countries": "國家", + "label.country": "國家", + "label.create": "建立", + "label.create-report": "建立報表", + "label.create-team": "建立團隊", + "label.create-user": "建立使用者", + "label.created": "已建立", + "label.created-by": "建立者", + "label.currency": "Currency", + "label.current": "目前", + "label.current-password": "目前密碼", + "label.custom-range": "自訂範圍", + "label.dashboard": "儀表板", + "label.data": "資料", + "label.date": "日期", + "label.date-range": "日期範圍", + "label.day": "日", + "label.default-date-range": "預設日期範圍", + "label.delete": "刪除", + "label.delete-report": "刪除報表", + "label.delete-team": "刪除團隊", + "label.delete-user": "刪除使用者", + "label.delete-website": "刪除網站", + "label.description": "描述", + "label.desktop": "桌上型電腦", + "label.details": "詳細資訊", + "label.device": "裝置", + "label.devices": "裝置", + "label.direct": "Direct", + "label.dismiss": "關閉", + "label.distinct-id": "Distinct ID", + "label.does-not-contain": "不包含", + "label.does-not-include": "Does not include", + "label.doest-not-exist": "Does not exist", + "label.domain": "網域", + "label.dropoff": "離開", + "label.edit": "編輯", + "label.edit-dashboard": "編輯儀表板", + "label.edit-member": "編輯成員", + "label.email": "Email", + "label.enable-share-url": "啟用分享連結", + "label.end-step": "結束步驟", + "label.entry": "進入網址", + "label.event": "事件", + "label.event-data": "事件資料", + "label.event-name": "Event name", + "label.events": "事件", + "label.exists": "Exists", + "label.exit": "離開網址", + "label.false": "否", + "label.field": "欄位", + "label.fields": "欄位", + "label.filter": "篩選器", + "label.filter-combined": "組合", + "label.filter-raw": "原始", + "label.filters": "篩選條件", + "label.first-click": "First click", + "label.first-seen": "首次造訪", + "label.funnel": "漏斗分析", + "label.funnel-description": "瞭解使用者的轉換率與流失率。", + "label.funnels": "Funnels", + "label.goal": "目標", + "label.goals": "目標", + "label.goals-description": "追蹤網頁瀏覽和事件的目標。", + "label.greater-than": "大於", + "label.greater-than-equals": "大於或等於", + "label.grouped": "Grouped", + "label.hostname": "Hostname", + "label.includes": "Includes", + "label.insight": "Insight", + "label.insights": "洞察", + "label.insights-description": "使用區段和篩選器來深入分析您的資料。", + "label.is": "是", + "label.is-false": "Is false", + "label.is-not": "不是", + "label.is-not-set": "未設定", + "label.is-set": "已設定", + "label.is-true": "Is true", + "label.join": "加入", + "label.join-team": "加入團隊", + "label.journey": "使用者旅程", + "label.journey-description": "瞭解使用者如何瀏覽您的網站。", + "label.journeys": "Journeys", + "label.language": "語言", + "label.languages": "語言", + "label.laptop": "筆記型電腦", + "label.last-click": "Last click", + "label.last-days": "最近 {x} 天", + "label.last-hours": "最近 {x} 小時", + "label.last-months": "最近 {x} 個月", + "label.last-seen": "最後造訪", + "label.leave": "離開", + "label.leave-team": "離開團隊", + "label.less-than": "小於", + "label.less-than-equals": "小於或等於", + "label.links": "Links", + "label.login": "登入", + "label.logout": "登出", + "label.manage": "管理", + "label.manager": "管理者", + "label.max": "最大值", + "label.maximize": "Expand", + "label.medium": "Medium", + "label.member": "成員", + "label.members": "成員", + "label.min": "最小值", + "label.mobile": "行動裝置", + "label.model": "Model", + "label.more": "更多", + "label.my-account": "我的帳號", + "label.my-websites": "我的網站", + "label.name": "名稱", + "label.new-password": "新密碼", + "label.none": "無", + "label.number-of-records": "{x} 筆紀錄", + "label.ok": "OK", + "label.online": "Online", + "label.organic-search": "Organic search", + "label.organic-shopping": "Organic shopping", + "label.organic-social": "Organic social", + "label.organic-video": "Organic video", + "label.os": "作業系統", + "label.other": "Other", + "label.overview": "總覽", + "label.owner": "擁有者", + "label.page": "Page", + "label.page-of": "第 {current} 頁,共 {total} 頁", + "label.page-views": "網頁瀏覽次數", + "label.pageTitle": "網頁標題", + "label.pages": "網頁", + "label.paid-ads": "Paid ads", + "label.paid-search": "Paid search", + "label.paid-shopping": "Paid shopping", + "label.paid-social": "Paid social", + "label.paid-video": "Paid video", + "label.password": "密碼", + "label.path": "路徑", + "label.paths": "路徑", + "label.pixels": "Pixels", + "label.powered-by": "由 {name} 提供技術支援", + "label.previous": "上一個", + "label.previous-period": "上一期間", + "label.previous-year": "去年", + "label.profile": "個人檔案", + "label.properties": "屬性", + "label.property": "屬性", + "label.queries": "查詢", + "label.query": "查詢", + "label.query-parameters": "查詢參數", + "label.realtime": "即時", + "label.referral": "Referral", + "label.referrer": "參照來源", + "label.referrers": "參照來源", + "label.refresh": "重新整理", + "label.regenerate": "重新產生", + "label.region": "地區", + "label.regions": "地區", + "label.remaining": "Remaining", + "label.remove": "移除", + "label.remove-member": "移除成員", + "label.reports": "報表", + "label.required": "必填", + "label.reset": "重設", + "label.reset-website": "重設網站統計資料", + "label.retention": "留存率", + "label.retention-description": "透過追蹤使用者回訪的頻率來衡量您的網站黏著度。", + "label.revenue": "營收", + "label.revenue-description": "查看您的營收趨勢。", + "label.role": "角色", + "label.run-query": "執行查詢", + "label.save": "儲存", + "label.screens": "螢幕", + "label.search": "搜尋", + "label.select": "選取", + "label.select-date": "選取日期", + "label.select-filter": "Select filter", + "label.select-role": "選取角色", + "label.select-website": "選取網站", + "label.session": "工作階段", + "label.session-data": "Session data", + "label.sessions": "工作階段", + "label.settings": "設定", + "label.share": "Share", + "label.share-url": "分享連結", + "label.single-day": "單日", + "label.sms": "SMS", + "label.sources": "Sources", + "label.start-step": "起始步驟", + "label.steps": "步驟", + "label.sum": "總和", + "label.tablet": "平板", + "label.tag": "Tag", + "label.tags": "Tags", + "label.team": "團隊", + "label.team-id": "團隊 ID", + "label.team-manager": "團隊管理者", + "label.team-member": "團隊成員", + "label.team-name": "團隊名稱", + "label.team-owner": "團隊擁有者", + "label.team-settings": "Team settings", + "label.team-view-only": "團隊僅供檢視", + "label.team-websites": "團隊網站", + "label.teams": "團隊", + "label.terms": "Terms", + "label.theme": "主題", + "label.this-month": "本月", + "label.this-week": "本週", + "label.this-year": "今年", + "label.timezone": "時區", + "label.title": "標題", + "label.today": "今天", + "label.toggle-charts": "切換圖表", + "label.total": "總計", + "label.total-records": "紀錄總數", + "label.tracking-code": "追蹤代碼", + "label.transactions": "交易", + "label.transfer": "轉移", + "label.transfer-website": "轉移網站", + "label.true": "是", + "label.type": "類型", + "label.unique": "不重複", + "label.unique-visitors": "不重複訪客", + "label.uniqueCustomers": "不重複客戶", + "label.unknown": "未知", + "label.untitled": "未命名", + "label.update": "更新", + "label.user": "使用者", + "label.username": "使用者名稱", + "label.users": "使用者", + "label.utm": "UTM", + "label.utm-description": "透過 UTM 參數追蹤您的行銷活動。", + "label.value": "值", + "label.view": "檢視", + "label.view-details": "檢視詳細資訊", + "label.view-only": "僅供檢視", + "label.views": "瀏覽次數", + "label.views-per-visit": "每次造訪的瀏覽次數", + "label.visit-duration": "造訪時間", + "label.visitors": "訪客", + "label.visits": "造訪次數", + "label.website": "網站", + "label.website-id": "網站 ID", + "label.websites": "網站", + "label.window": "視窗", + "label.yesterday": "昨天", + "message.action-confirmation": "請在下方欄位輸入 {confirmation} 以確認。", + "message.active-users": "目前有 {x} 位訪客", + "message.bad-request": "Bad request", + "message.collected-data": "已蒐集的資料", + "message.confirm-delete": "您確定要刪除 {target} 嗎?", + "message.confirm-leave": "您確定要離開 {target} 嗎?", + "message.confirm-remove": "您確定要移除 {target} 嗎?", + "message.confirm-reset": "您確定要重設 {target} 的統計資料嗎?", + "message.delete-team-warning": "刪除團隊的同時也會刪除所有團隊的網站。", + "message.delete-website-warning": "所有網站資料都將被刪除。", + "message.error": "發生錯誤。", + "message.event-log": "在 {url} 上的 {event}", + "message.forbidden": "Forbidden", + "message.go-to-settings": "前往設定", + "message.incorrect-username-password": "使用者名稱或密碼不正確。", + "message.invalid-domain": "無效的網域。請勿包含 http/https。", + "message.min-password-length": "密碼長度至少需 {n} 個字元", + "message.new-version-available": "Umami {version} 的新版本已推出!", + "message.no-data-available": "沒有可用的資料。", + "message.no-event-data": "沒有可用的事件資料。", + "message.no-match-password": "密碼不一致。", + "message.no-results-found": "找不到結果。", + "message.no-team-websites": "此團隊沒有任何網站。", + "message.no-teams": "您尚未建立任何團隊。", + "message.no-users": "沒有任何使用者。", + "message.no-websites-configured": "您尚未設定任何網站。", + "message.not-found": "Not found", + "message.nothing-selected": "Nothing selected.", + "message.page-not-found": "找不到網頁", + "message.reset-website": "要重設此網站的統計資料,請在下方欄位輸入 {confirmation} 以確認。", + "message.reset-website-warning": "此網站的所有統計資料都將被刪除,但您的設定將保持不變。", + "message.saved": "已儲存。", + "message.sever-error": "Server error", + "message.share-url": "您的網站統計資料可在以下網址公開檢視:", + "message.team-already-member": "您已是該團隊的成員。", + "message.team-not-found": "找不到團隊。", + "message.team-websites-info": "團隊中的所有成員都可以檢視網站。", + "message.tracking-code": "要追蹤此網站的統計資料,請將以下程式碼放在您 HTML 的 <head>...</head> 區段中。", + "message.transfer-team-website-to-user": "要將此網站轉移至您的帳號嗎?", + "message.transfer-user-website-to-team": "請選擇要轉移此網站的團隊。", + "message.transfer-website": "將網站所有權轉移至您的帳號或其他團隊。", + "message.triggered-event": "已觸發的事件", + "message.unauthorized": "Unauthorized", + "message.user-deleted": "使用者已刪除。", + "message.viewed-page": "已瀏覽的網頁", + "message.visitor-log": "來自 {country} 的訪客在 {device} 上的 {os} 使用 {browser} 瀏覽。" +} diff --git a/src/lib/__tests__/charts.test.ts b/src/lib/__tests__/charts.test.ts new file mode 100644 index 0000000..e81be16 --- /dev/null +++ b/src/lib/__tests__/charts.test.ts @@ -0,0 +1,39 @@ +import { renderNumberLabels } from '../charts'; + +// test for renderNumberLabels + +describe('renderNumberLabels', () => { + test.each([ + ['1000000', '1.0m'], + ['2500000', '2.5m'], + ])("formats numbers ≥ 1 million as 'Xm' (%s → %s)", (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); + + test.each([['150000', '150k']])("formats numbers ≥ 100K as 'Xk' (%s → %s)", (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); + + test.each([ + ['12500', '12.5k'], + ])("formats numbers ≥ 10K as 'X.Xk' (%s → %s)", (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); + + test.each([['1500', '1.50k']])("formats numbers ≥ 1K as 'X.XXk' (%s → %s)", (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); + + test.each([ + ['999', '999'], + ])('calls formatNumber for values < 1000 (%s → %s)', (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); + + test.each([ + ['0', '0'], + ['-5000', '-5000'], + ])('handles edge cases correctly (%s → %s)', (input, expected) => { + expect(renderNumberLabels(input)).toBe(expected); + }); +}); diff --git a/src/lib/__tests__/detect.test.ts b/src/lib/__tests__/detect.test.ts new file mode 100644 index 0000000..0395aef --- /dev/null +++ b/src/lib/__tests__/detect.test.ts @@ -0,0 +1,22 @@ +import { getIpAddress } from '../ip'; + +const IP = '127.0.0.1'; +const BAD_IP = '127.127.127.127'; + +test('getIpAddress: Custom header', () => { + process.env.CLIENT_IP_HEADER = 'x-custom-ip-header'; + + expect(getIpAddress(new Headers({ 'x-custom-ip-header': IP }))).toEqual(IP); +}); + +test('getIpAddress: CloudFlare header', () => { + expect(getIpAddress(new Headers({ 'cf-connecting-ip': IP }))).toEqual(IP); +}); + +test('getIpAddress: Standard header', () => { + expect(getIpAddress(new Headers({ 'x-forwarded-for': IP }))).toEqual(IP); +}); + +test('getIpAddress: No header', () => { + expect(getIpAddress(new Headers())).toEqual(null); +}); diff --git a/src/lib/__tests__/format.test.ts b/src/lib/__tests__/format.test.ts new file mode 100644 index 0000000..6e1b319 --- /dev/null +++ b/src/lib/__tests__/format.test.ts @@ -0,0 +1,38 @@ +import * as format from '../format'; + +test('parseTime', () => { + expect(format.parseTime(86400 + 3600 + 60 + 1)).toEqual({ + days: 1, + hours: 1, + minutes: 1, + seconds: 1, + ms: 0, + }); +}); + +test('formatTime', () => { + expect(format.formatTime(3600 + 60 + 1)).toBe('1:01:01'); +}); + +test('formatShortTime', () => { + expect(format.formatShortTime(3600 + 60 + 1)).toBe('1m1s'); + + expect(format.formatShortTime(3600 + 60 + 1, ['h', 'm', 's'])).toBe('1h1m1s'); +}); + +test('formatNumber', () => { + expect(format.formatNumber('10.2')).toBe('10'); + expect(format.formatNumber('10.5')).toBe('11'); +}); + +test('formatLongNumber', () => { + expect(format.formatLongNumber(1200000)).toBe('1.2m'); + expect(format.formatLongNumber(575000)).toBe('575k'); + expect(format.formatLongNumber(10500)).toBe('10.5k'); + expect(format.formatLongNumber(1200)).toBe('1.20k'); +}); + +test('stringToColor', () => { + expect(format.stringToColor('hello')).toBe('#d218e9'); + expect(format.stringToColor('goodbye')).toBe('#11e956'); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..ba6d8b0 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,80 @@ +import debug from 'debug'; +import { ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from '@/lib/constants'; +import { secret } from '@/lib/crypto'; +import { getRandomChars } from '@/lib/generate'; +import { createSecureToken, parseSecureToken, parseToken } from '@/lib/jwt'; +import redis from '@/lib/redis'; +import { ensureArray } from '@/lib/utils'; +import { getUser } from '@/queries/prisma/user'; + +const log = debug('umami:auth'); + +export function getBearerToken(request: Request) { + const auth = request.headers.get('authorization'); + + return auth?.split(' ')[1]; +} + +export async function checkAuth(request: Request) { + const token = getBearerToken(request); + const payload = parseSecureToken(token, secret()); + const shareToken = await parseShareToken(request); + + let user = null; + const { userId, authKey } = payload || {}; + + if (userId) { + user = await getUser(userId); + } else if (redis.enabled && authKey) { + const key = await redis.client.get(authKey); + + if (key?.userId) { + user = await getUser(key.userId); + } + } + + log({ token, payload, authKey, shareToken, user }); + + if (!user?.id && !shareToken) { + log('User not authorized'); + return null; + } + + if (user) { + user.isAdmin = user.role === ROLES.admin; + } + + return { + token, + authKey, + shareToken, + user, + }; +} + +export async function saveAuth(data: any, expire = 0) { + const authKey = `auth:${getRandomChars(32)}`; + + if (redis.enabled) { + await redis.client.set(authKey, data); + + if (expire) { + await redis.client.expire(authKey, expire); + } + } + + return createSecureToken({ authKey }, secret()); +} + +export async function hasPermission(role: string, permission: string | string[]) { + return ensureArray(permission).some(e => ROLE_PERMISSIONS[role]?.includes(e)); +} + +export function parseShareToken(request: Request) { + try { + return parseToken(request.headers.get(SHARE_TOKEN_HEADER), secret()); + } catch (e) { + log(e); + return null; + } +} diff --git a/src/lib/charts.ts b/src/lib/charts.ts new file mode 100644 index 0000000..7d4208e --- /dev/null +++ b/src/lib/charts.ts @@ -0,0 +1,27 @@ +import { formatDate } from '@/lib/date'; +import { formatLongNumber } from '@/lib/format'; + +export function renderNumberLabels(label: string) { + return +label > 1000 ? formatLongNumber(+label) : label; +} + +export function renderDateLabels(unit: string, locale: string) { + return (label: string, index: number, values: any[]) => { + const d = new Date(values[index].value); + + switch (unit) { + case 'minute': + return formatDate(d, 'h:mm', locale); + case 'hour': + return formatDate(d, 'p', locale); + case 'day': + return formatDate(d, 'PP', locale).replace(/\W*20\d{2}\W*/, ''); // Remove year + case 'month': + return formatDate(d, 'MMM', locale); + case 'year': + return formatDate(d, 'yyyy', locale); + default: + return label; + } + }; +} diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts new file mode 100644 index 0000000..f2ebbb7 --- /dev/null +++ b/src/lib/clickhouse.ts @@ -0,0 +1,273 @@ +import { type ClickHouseClient, createClient } from '@clickhouse/client'; +import { formatInTimeZone } from 'date-fns-tz'; +import debug from 'debug'; +import { CLICKHOUSE } from '@/lib/db'; +import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS } from './constants'; +import { filtersObjectToArray } from './params'; +import type { QueryFilters, QueryOptions } from './types'; + +export const CLICKHOUSE_DATE_FORMATS = { + utc: '%Y-%m-%dT%H:%i:%SZ', + second: '%Y-%m-%d %H:%i:%S', + minute: '%Y-%m-%d %H:%i:00', + hour: '%Y-%m-%d %H:00:00', + day: '%Y-%m-%d', + month: '%Y-%m-01', + year: '%Y-01-01', +}; + +const log = debug('umami:clickhouse'); + +let clickhouse: ClickHouseClient; +const enabled = Boolean(process.env.CLICKHOUSE_URL); + +function getClient() { + const { + hostname, + port, + pathname, + protocol, + username = 'default', + password, + } = new URL(process.env.CLICKHOUSE_URL); + + const client = createClient({ + url: `${protocol}//${hostname}:${port}`, + database: pathname.replace('/', ''), + username: username, + password, + }); + + if (process.env.NODE_ENV !== 'production') { + globalThis[CLICKHOUSE] = client; + } + + log('Clickhouse initialized'); + + return client; +} + +function getUTCString(date?: Date | string | number) { + return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss'); +} + +function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) { + if (timezone) { + return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`; + } + + return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}')`; +} + +function getDateSQL(field: string, unit: string, timezone?: string) { + if (timezone) { + return `toDateTime(date_trunc('${unit}', ${field}, '${timezone}'))`; + } + return `toDateTime(date_trunc('${unit}', ${field}))`; +} + +function getSearchSQL(column: string, param: string = 'search'): string { + return `and positionCaseInsensitive(${column}, {${param}:String}) > 0`; +} + +function mapFilter(column: string, operator: string, name: string, type: string = 'String') { + const value = `{${name}:${type}}`; + + switch (operator) { + case OPERATORS.equals: + return `${column} = ${value}`; + case OPERATORS.notEquals: + return `${column} != ${value}`; + case OPERATORS.contains: + return `positionCaseInsensitive(${column}, ${value}) > 0`; + case OPERATORS.doesNotContain: + return `positionCaseInsensitive(${column}, ${value}) = 0`; + default: + return ''; + } +} + +function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}) { + const query = filtersObjectToArray(filters, options).reduce((arr, { name, column, operator }) => { + const isCohort = options?.isCohort; + + if (isCohort) { + column = FILTER_COLUMNS[name.slice('cohort_'.length)]; + } + + if (column) { + if (name === 'eventType') { + arr.push(`and ${mapFilter(column, operator, name, 'UInt32')}`); + } else { + arr.push(`and ${mapFilter(column, operator, name)}`); + } + + if (name === 'referrer') { + arr.push(`and referrer_domain != hostname`); + } + } + + return arr; + }, []); + + return query.join('\n'); +} + +function getCohortQuery(filters: Record<string, any>) { + if (!filters || Object.keys(filters).length === 0) { + return ''; + } + + const filterQuery = getFilterQuery(filters, { isCohort: true }); + + return `join ( + select distinct session_id + from website_event + where website_id = {websiteId:UUID} + and created_at between {cohort_startDate:DateTime64} and {cohort_endDate:DateTime64} + ${filterQuery} + ) as cohort + on cohort.session_id = website_event.session_id + `; +} + +function getDateQuery(filters: Record<string, any>) { + const { startDate, endDate, timezone } = filters; + + if (startDate) { + if (endDate) { + if (timezone) { + return `and created_at between toTimezone({startDate:DateTime64},{timezone:String}) and toTimezone({endDate:DateTime64},{timezone:String})`; + } + return `and created_at between {startDate:DateTime64} and {endDate:DateTime64}`; + } else { + if (timezone) { + return `and created_at >= toTimezone({startDate:DateTime64},{timezone:String})`; + } + return `and created_at >= {startDate:DateTime64}`; + } + } + + return ''; +} + +function getQueryParams(filters: Record<string, any>) { + return { + ...filters, + ...filtersObjectToArray(filters).reduce((obj, { name, value }) => { + if (name && value !== undefined) { + obj[name] = value; + } + + return obj; + }, {}), + }; +} + +function parseFilters(filters: Record<string, any>, options?: QueryOptions) { + const cohortFilters = Object.fromEntries( + Object.entries(filters).filter(([key]) => key.startsWith('cohort_')), + ); + + return { + filterQuery: getFilterQuery(filters, options), + dateQuery: getDateQuery(filters), + queryParams: getQueryParams(filters), + cohortQuery: getCohortQuery(cohortFilters), + }; +} + +async function pagedRawQuery( + query: string, + queryParams: Record<string, any>, + filters: QueryFilters, + name?: string, +) { + const { page = 1, pageSize, orderBy, sortDescending = false, search } = filters; + const size = +pageSize || DEFAULT_PAGE_SIZE; + const offset = +size * (+page - 1); + const direction = sortDescending ? 'desc' : 'asc'; + + const statements = [ + orderBy && `order by ${orderBy} ${direction}`, + +size > 0 && `limit ${+size} offset ${+offset}`, + ] + .filter(n => n) + .join('\n'); + + const count = await rawQuery(`select count(*) as num from (${query}) t`, queryParams).then( + res => res[0].num, + ); + + const data = await rawQuery(`${query}${statements}`, queryParams, name); + + return { data, count, page: +page, pageSize: size, orderBy, search }; +} + +async function rawQuery<T = unknown>( + query: string, + params: Record<string, unknown> = {}, + name?: string, +): Promise<T> { + if (process.env.LOG_QUERY) { + log({ query, params, name }); + } + + await connect(); + + const resultSet = await clickhouse.query({ + query: query, + query_params: params, + format: 'JSONEachRow', + clickhouse_settings: { + date_time_output_format: 'iso', + output_format_json_quote_64bit_integers: 0, + }, + }); + + return (await resultSet.json()) as T; +} + +async function insert(table: string, values: any[]) { + await connect(); + + return clickhouse.insert({ table, values, format: 'JSONEachRow' }); +} + +async function findUnique(data: any[]) { + if (data.length > 1) { + throw `${data.length} records found when expecting 1.`; + } + + return findFirst(data); +} + +async function findFirst(data: any[]) { + return data[0] ?? null; +} + +async function connect() { + if (enabled && !clickhouse) { + clickhouse = process.env.CLICKHOUSE_URL && (globalThis[CLICKHOUSE] || getClient()); + } + + return clickhouse; +} + +export default { + enabled, + client: clickhouse, + log, + connect, + getDateStringSQL, + getDateSQL, + getSearchSQL, + getFilterQuery, + getUTCString, + parseFilters, + pagedRawQuery, + findUnique, + findFirst, + rawQuery, + insert, +}; diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..e176215 --- /dev/null +++ b/src/lib/client.ts @@ -0,0 +1,14 @@ +import { getItem, removeItem, setItem } from '@/lib/storage'; +import { AUTH_TOKEN } from './constants'; + +export function getClientAuthToken() { + return getItem(AUTH_TOKEN); +} + +export function setClientAuthToken(token: string) { + setItem(AUTH_TOKEN, token); +} + +export function removeClientAuthToken() { + removeItem(AUTH_TOKEN); +} diff --git a/src/lib/colors.ts b/src/lib/colors.ts new file mode 100644 index 0000000..2ae9bda --- /dev/null +++ b/src/lib/colors.ts @@ -0,0 +1,91 @@ +import { colord } from 'colord'; +import { THEME_COLORS } from '@/lib/constants'; + +export function hex6(str: string) { + let h = 0x811c9dc5; // FNV-1a 32-bit offset + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = (h >>> 0) * 0x01000193; // FNV prime + } + // use lower 24 bits; pad to 6 hex chars + return ((h >>> 0) & 0xffffff).toString(16).padStart(6, '0'); +} + +export const pick = (num: number, arr: any[]) => { + return arr[num % arr.length]; +}; + +export function clamp(num: number, min: number, max: number) { + return num < min ? min : num > max ? max : num; +} + +export function hex2RGB(color: string, min: number = 0, max: number = 255) { + const c = color.replace(/^#/, ''); + const diff = max - min; + + const normalize = (num: number) => { + return Math.floor((num / 255) * diff + min); + }; + + const r = normalize(parseInt(c.substring(0, 2), 16)); + const g = normalize(parseInt(c.substring(2, 4), 16)); + const b = normalize(parseInt(c.substring(4, 6), 16)); + + return { r, g, b }; +} + +export function rgb2Hex(r: number, g: number, b: number, prefix = '') { + return `${prefix}${r.toString(16)}${g.toString(16)}${b.toString(16)}`; +} + +export function getPastel(color: string, factor: number = 0.5, prefix = '') { + let { r, g, b } = hex2RGB(color); + + r = Math.floor((r + 255 * factor) / (1 + factor)); + g = Math.floor((g + 255 * factor) / (1 + factor)); + b = Math.floor((b + 255 * factor) / (1 + factor)); + + return rgb2Hex(r, g, b, prefix); +} + +export function getColor(seed: string, min: number = 0, max: number = 255) { + const color = hex6(seed); + const { r, g, b } = hex2RGB(color, min, max); + + return rgb2Hex(r, g, b); +} + +export function getThemeColors(theme: string) { + const { primary, text, line, fill } = THEME_COLORS[theme]; + const primaryColor = colord(THEME_COLORS[theme].primary); + + return { + colors: { + theme: { + ...THEME_COLORS[theme], + }, + chart: { + text, + line, + views: { + hoverBackgroundColor: primaryColor.alpha(0.7).toRgbString(), + backgroundColor: primaryColor.alpha(0.4).toRgbString(), + borderColor: primaryColor.alpha(0.7).toRgbString(), + hoverBorderColor: primaryColor.toRgbString(), + }, + visitors: { + hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(), + backgroundColor: primaryColor.alpha(0.6).toRgbString(), + borderColor: primaryColor.alpha(0.9).toRgbString(), + hoverBorderColor: primaryColor.toRgbString(), + }, + }, + map: { + baseColor: primary, + fillColor: fill, + strokeColor: primary, + hoverColor: primary, + }, + }, + }; +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..e5090c3 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,682 @@ +export const CURRENT_VERSION = process.env.currentVersion; +export const AUTH_TOKEN = 'umami.auth'; +export const LOCALE_CONFIG = 'umami.locale'; +export const TIMEZONE_CONFIG = 'umami.timezone'; +export const DATE_RANGE_CONFIG = 'umami.date-range'; +export const THEME_CONFIG = 'umami.theme'; +export const DASHBOARD_CONFIG = 'umami.dashboard'; +export const LAST_TEAM_CONFIG = 'umami.last-team'; +export const VERSION_CHECK = 'umami.version-check'; +export const SHARE_TOKEN_HEADER = 'x-umami-share-token'; +export const HOMEPAGE_URL = 'https://umami.is'; +export const DOCS_URL = 'https://umami.is/docs'; +export const REPO_URL = 'https://github.com/umami-software/umami'; +export const UPDATES_URL = 'https://api.umami.is/v1/updates'; +export const TELEMETRY_PIXEL = 'https://i.umami.is/a.png'; +export const FAVICON_URL = 'https://icons.duckduckgo.com/ip3/{{domain}}.ico'; +export const LINKS_URL = `${globalThis?.location?.origin}/q`; +export const PIXELS_URL = `${globalThis?.location?.origin}/p`; + +export const DEFAULT_LOCALE = 'en-US'; +export const DEFAULT_THEME = 'light'; +export const DEFAULT_ANIMATION_DURATION = 300; +export const DEFAULT_DATE_RANGE_VALUE = '24hour'; +export const DEFAULT_WEBSITE_LIMIT = 10; +export const DEFAULT_RESET_DATE = '2000-01-01'; +export const DEFAULT_PAGE_SIZE = 20; +export const DEFAULT_DATE_COMPARE = 'prev'; + +export const REALTIME_RANGE = 30; +export const REALTIME_INTERVAL = 10000; + +export const UNIT_TYPES = ['year', 'month', 'hour', 'day', 'minute']; + +export const EVENT_COLUMNS = [ + 'path', + 'entry', + 'exit', + 'referrer', + 'domain', + 'title', + 'query', + 'event', + 'tag', + 'hostname', +]; + +export const SESSION_COLUMNS = [ + 'browser', + 'os', + 'device', + 'screen', + 'language', + 'country', + 'city', + 'region', +]; + +export const SEGMENT_TYPES = { + segment: 'segment', + cohort: 'cohort', +}; + +export const FILTER_COLUMNS = { + path: 'url_path', + entry: 'url_path', + exit: 'url_path', + referrer: 'referrer_domain', + domain: 'referrer_domain', + hostname: 'hostname', + title: 'page_title', + query: 'url_query', + os: 'os', + browser: 'browser', + device: 'device', + country: 'country', + region: 'region', + city: 'city', + language: 'language', + event: 'event_name', + tag: 'tag', + eventType: 'event_type', +}; + +export const COLLECTION_TYPE = { + event: 'event', + identify: 'identify', +} as const; + +export const EVENT_TYPE = { + pageView: 1, + customEvent: 2, + linkEvent: 3, + pixelEvent: 4, +} as const; + +export const DATA_TYPE = { + string: 1, + number: 2, + boolean: 3, + date: 4, + array: 5, +} as const; + +export const OPERATORS = { + equals: 'eq', + notEquals: 'neq', + set: 's', + notSet: 'ns', + contains: 'c', + doesNotContain: 'dnc', + true: 't', + false: 'f', + greaterThan: 'gt', + lessThan: 'lt', + greaterThanEquals: 'gte', + lessThanEquals: 'lte', + before: 'bf', + after: 'af', +} as const; + +export const DATA_TYPES = { + [DATA_TYPE.string]: 'string', + [DATA_TYPE.number]: 'number', + [DATA_TYPE.boolean]: 'boolean', + [DATA_TYPE.date]: 'date', + [DATA_TYPE.array]: 'array', +} as const; + +export const ROLES = { + admin: 'admin', + user: 'user', + viewOnly: 'view-only', + teamOwner: 'team-owner', + teamManager: 'team-manager', + teamMember: 'team-member', + teamViewOnly: 'team-view-only', +} as const; + +export const PERMISSIONS = { + all: 'all', + websiteCreate: 'website:create', + websiteUpdate: 'website:update', + websiteDelete: 'website:delete', + websiteTransferToTeam: 'website:transfer-to-team', + websiteTransferToUser: 'website:transfer-to-user', + teamCreate: 'team:create', + teamUpdate: 'team:update', + teamDelete: 'team:delete', +} as const; + +export const ROLE_PERMISSIONS = { + [ROLES.admin]: [PERMISSIONS.all], + [ROLES.user]: [ + PERMISSIONS.websiteCreate, + PERMISSIONS.websiteUpdate, + PERMISSIONS.websiteDelete, + PERMISSIONS.teamCreate, + ], + [ROLES.viewOnly]: [], + [ROLES.teamOwner]: [ + PERMISSIONS.teamUpdate, + PERMISSIONS.teamDelete, + PERMISSIONS.websiteCreate, + PERMISSIONS.websiteUpdate, + PERMISSIONS.websiteDelete, + PERMISSIONS.websiteTransferToTeam, + PERMISSIONS.websiteTransferToUser, + ], + [ROLES.teamManager]: [ + PERMISSIONS.teamUpdate, + PERMISSIONS.websiteCreate, + PERMISSIONS.websiteUpdate, + PERMISSIONS.websiteDelete, + PERMISSIONS.websiteTransferToTeam, + ], + [ROLES.teamMember]: [ + PERMISSIONS.websiteCreate, + PERMISSIONS.websiteUpdate, + PERMISSIONS.websiteDelete, + ], + [ROLES.teamViewOnly]: [], +} as const; + +export const THEME_COLORS = { + light: { + primary: '#2680eb', + text: '#838383', + line: '#d9d9d9', + fill: '#f9f9f9', + }, + dark: { + primary: '#2680eb', + text: '#7b7b7b', + line: '#3a3a3a', + fill: '#191919', + }, +} as const; + +export const CHART_COLORS = [ + '#2680eb', + '#9256d9', + '#44b556', + '#e68619', + '#e34850', + '#f7bd12', + '#01bad7', + '#6734bc', + '#89c541', + '#ffc301', + '#ec1562', + '#ffec16', +]; + +export const DOMAIN_REGEX = + /^(localhost(:[1-9]\d{0,4})?|((?=[a-z0-9-_]{1,63}\.)(xn--)?[a-z0-9-_]+(-[a-z0-9-_]+)*\.)+(xn--)?[a-z0-9-_]{2,63})$/; +export const SHARE_ID_REGEX = /^[a-zA-Z0-9]{8,50}$/; +export const DATETIME_REGEX = + /^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{3}(Z|\+[0-9]{2}:[0-9]{2})?)?$/; + +export const URL_LENGTH = 500; +export const PAGE_TITLE_LENGTH = 500; +export const EVENT_NAME_LENGTH = 50; + +export const UTM_PARAMS = ['utm_campaign', 'utm_content', 'utm_medium', 'utm_source', 'utm_term']; + +export const OS_NAMES = { + 'Android OS': 'Android', + 'Chrome OS': 'ChromeOS', + 'Mac OS': 'macOS', + 'Sun OS': 'SunOS', + 'Windows 10': 'Windows 10/11', +} as const; + +export const BROWSERS = { + android: 'Android', + aol: 'AOL', + bb10: 'BlackBerry 10', + beaker: 'Beaker', + chrome: 'Chrome', + 'chromium-webview': 'Chrome (webview)', + crios: 'Chrome (iOS)', + curl: 'Curl', + edge: 'Edge', + 'edge-chromium': 'Edge (Chromium)', + 'edge-ios': 'Edge (iOS)', + facebook: 'Facebook', + firefox: 'Firefox', + fxios: 'Firefox (iOS)', + ie: 'IE', + instagram: 'Instagram', + ios: 'iOS', + 'ios-webview': 'iOS (webview)', + kakaotalk: 'KakaoTalk', + miui: 'MIUI', + opera: 'Opera', + 'opera-mini': 'Opera Mini', + phantomjs: 'PhantomJS', + safari: 'Safari', + samsung: 'Samsung', + searchbot: 'Searchbot', + silk: 'Silk', + yandexbrowser: 'Yandex', +} as const; + +export const SOCIAL_DOMAINS = [ + 'bsky.app', + 'facebook.com', + 'fb.com', + 'ig.com', + 'instagram.com', + 'linkedin.', + 'news.ycombinator.com', + 'pinterest.', + 'reddit.', + 'snapchat.', + 't.co', + 'threads.net', + 'tiktok.', + 'twitter.com', + 'x.com', +]; + +export const SEARCH_DOMAINS = [ + 'baidu.com', + 'bing.com', + 'chatgpt.com', + 'duckduckgo.com', + 'ecosia.org', + 'google.', + 'msn.com', + 'perplexity.ai', + 'search.brave.com', + 'yandex.', +]; + +export const SHOPPING_DOMAINS = [ + 'alibaba.com', + 'aliexpress.com', + 'amazon.', + 'bestbuy.com', + 'ebay.com', + 'etsy.com', + 'newegg.com', + 'target.com', + 'walmart.com', +]; + +export const EMAIL_DOMAINS = [ + 'gmail.', + 'hotmail.', + 'mail.yahoo.', + 'outlook.', + 'proton.me', + 'protonmail.', +]; + +export const VIDEO_DOMAINS = ['twitch.', 'youtube.']; + +export const PAID_AD_PARAMS = [ + 'ad_id=', + 'aid=', + 'dclid=', + 'epik=', + 'fbclid=', + 'gclid=', + 'li_fat_id=', + 'msclkid=', + 'ob_click_id=', + 'pc_id=', + 'rdt_cid=', + 'scid=', + 'ttclid=', + 'twclid=', + 'utm_medium=cpc', + 'utm_medium=paid', + 'utm_medium=paid_social', + 'utm_source=google', +]; + +export const GROUPED_DOMAINS = [ + { name: 'Baidu', domain: 'baidu.com', match: 'baidu.' }, + { name: 'Bing', domain: 'bing.com', match: 'bing.' }, + { name: 'Brave', domain: 'brave.com', match: 'brave.' }, + { name: 'ChatGPT', domain: 'chatgpt.com', match: 'chatgpt.' }, + { name: 'DuckDuckGo', domain: 'duckduckgo.com', match: 'duckduckgo.' }, + { name: 'Facebook', domain: 'facebook.com', match: 'facebook.' }, + { name: 'GitHub', domain: 'github.com', match: 'github.' }, + { name: 'Google', domain: 'google.com', match: 'google.' }, + { name: 'Hacker News', domain: 'news.ycombinator.com', match: 'news.ycombinator.com' }, + { name: 'Instagram', domain: 'instagram.com', match: ['instagram.', 'ig.com'] }, + { name: 'LinkedIn', domain: 'linkedin.com', match: 'linkedin.' }, + { name: 'Pinterest', domain: 'pinterest.com', match: 'pinterest.' }, + { name: 'Reddit', domain: 'reddit.com', match: 'reddit.' }, + { name: 'Snapchat', domain: 'snapchat.com', match: 'snapchat.' }, + { name: 'Twitter', domain: 'twitter.com', match: ['twitter.', 't.co', 'x.com'] }, + { name: 'Yahoo', domain: 'yahoo.com', match: 'yahoo.' }, + { name: 'Yandex', domain: 'yandex.ru', match: 'yandex.' }, +]; + +export const MAP_FILE = '/datamaps.world.json'; + +export const ISO_COUNTRIES = { + ABW: 'AW', + AFG: 'AF', + AGO: 'AO', + AIA: 'AI', + ALA: 'AX', + ALB: 'AL', + AND: 'AD', + ANT: 'AN', + ARE: 'AE', + ARG: 'AR', + ARM: 'AM', + ASM: 'AS', + ATF: 'TF', + ATG: 'AG', + AUS: 'AU', + AUT: 'AT', + AZE: 'AZ', + BDI: 'BI', + BEL: 'BE', + BEN: 'BJ', + BFA: 'BF', + BGD: 'BD', + BGR: 'BG', + BHR: 'BH', + BHS: 'BS', + BIH: 'BA', + BLR: 'BY', + BLZ: 'BZ', + BLM: 'BL', + BMU: 'BM', + BOL: 'BO', + BRA: 'BR', + BRB: 'BB', + BRN: 'BN', + BTN: 'BT', + BVT: 'BV', + BWA: 'BW', + CAF: 'CF', + CAN: 'CA', + CCK: 'CC', + CHE: 'CH', + CHL: 'CL', + CHN: 'CN', + CIV: 'CI', + CMR: 'CM', + COD: 'CD', + COG: 'CG', + COK: 'CK', + COL: 'CO', + COM: 'KM', + CPV: 'CV', + CRI: 'CR', + CUB: 'CU', + CXR: 'CX', + CYM: 'KY', + CYP: 'CY', + CZE: 'CZ', + DEU: 'DE', + DJI: 'DJ', + DMA: 'DM', + DNK: 'DK', + DOM: 'DO', + DZA: 'DZ', + ECU: 'EC', + EGY: 'EG', + ERI: 'ER', + ESH: 'EH', + ESP: 'ES', + EST: 'EE', + ETH: 'ET', + FIN: 'FI', + FJI: 'FJ', + FLK: 'FK', + FRA: 'FR', + FRO: 'FO', + FSM: 'FM', + GAB: 'GA', + GBR: 'GB', + GEO: 'GE', + GGY: 'GG', + GHA: 'GH', + GIB: 'GI', + GIN: 'GN', + GLP: 'GP', + GMB: 'GM', + GNB: 'GW', + GNQ: 'GQ', + GRC: 'GR', + GRD: 'GD', + GRL: 'GL', + GTM: 'GT', + GUF: 'GF', + GUM: 'GU', + GUY: 'GY', + HKG: 'HK', + HMD: 'HM', + HND: 'HN', + HRV: 'HR', + HTI: 'HT', + HUN: 'HU', + IDN: 'ID', + IMN: 'IM', + IND: 'IN', + IOT: 'IO', + IRL: 'IE', + IRN: 'IR', + IRQ: 'IQ', + ISL: 'IS', + ISR: 'IL', + ITA: 'IT', + JAM: 'JM', + JEY: 'JE', + JOR: 'JO', + JPN: 'JP', + KAZ: 'KZ', + KEN: 'KE', + KGZ: 'KG', + KHM: 'KH', + KIR: 'KI', + KNA: 'KN', + KOR: 'KR', + KWT: 'KW', + LAO: 'LA', + LBN: 'LB', + LBR: 'LR', + LBY: 'LY', + LCA: 'LC', + LIE: 'LI', + LKA: 'LK', + LSO: 'LS', + LTU: 'LT', + LUX: 'LU', + LVA: 'LV', + MAF: 'MF', + MAR: 'MA', + MCO: 'MC', + MDA: 'MD', + MDG: 'MG', + MDV: 'MV', + MEX: 'MX', + MHL: 'MH', + MKD: 'MK', + MLI: 'ML', + MLT: 'MT', + MMR: 'MM', + MNE: 'ME', + MNG: 'MN', + MNP: 'MP', + MOZ: 'MZ', + MRT: 'MR', + MSR: 'MS', + MTQ: 'MQ', + MUS: 'MU', + MWI: 'MW', + MYS: 'MY', + MYT: 'YT', + NAM: 'NA', + NCL: 'NC', + NER: 'NE', + NFK: 'NF', + NGA: 'NG', + NIC: 'NI', + NIU: 'NU', + NLD: 'NL', + NOR: 'NO', + NPL: 'NP', + NRU: 'NR', + NZL: 'NZ', + OMN: 'OM', + PAK: 'PK', + PAN: 'PA', + PCN: 'PN', + PER: 'PE', + PHL: 'PH', + PLW: 'PW', + PNG: 'PG', + POL: 'PL', + PRI: 'PR', + PRK: 'KP', + PRT: 'PT', + PRY: 'PY', + PSE: 'PS', + PYF: 'PF', + QAT: 'QA', + REU: 'RE', + ROU: 'RO', + RUS: 'RU', + RWA: 'RW', + SAU: 'SA', + SDN: 'SD', + SEN: 'SN', + SGP: 'SG', + SGS: 'GS', + SHN: 'SH', + SJM: 'SJ', + SLB: 'SB', + SLE: 'SL', + SLV: 'SV', + SMR: 'SM', + SOM: 'SO', + SPM: 'PM', + SRB: 'RS', + SUR: 'SR', + STP: 'ST', + SVK: 'SK', + SVN: 'SI', + SWE: 'SE', + SWZ: 'SZ', + SYC: 'SC', + SYR: 'SY', + TCA: 'TC', + TCD: 'TD', + TGO: 'TG', + THA: 'TH', + TJK: 'TJ', + TKL: 'TK', + TKM: 'TM', + TLS: 'TL', + TON: 'TO', + TTO: 'TT', + TUN: 'TN', + TUR: 'TR', + TUV: 'TV', + TWN: 'TW', + TZA: 'TZ', + UGA: 'UG', + UKR: 'UA', + UMI: 'UM', + URY: 'UY', + USA: 'US', + UZB: 'UZ', + VAT: 'VA', + VCT: 'VC', + VEN: 'VE', + VGB: 'VG', + VIR: 'VI', + VNM: 'VN', + VUT: 'VU', + WLF: 'WF', + WSM: 'WS', + XKX: 'XK', + YEM: 'YE', + ZAF: 'ZA', + ZMB: 'ZM', + ZWE: 'ZW', +}; + +export const CURRENCIES = [ + { id: 'USD', name: 'US Dollar' }, + { id: 'EUR', name: 'Euro' }, + { id: 'GBP', name: 'British Pound' }, + { id: 'JPY', name: 'Japanese Yen' }, + { id: 'CNY', name: 'Chinese Renminbi (Yuan)' }, + { id: 'CAD', name: 'Canadian Dollar' }, + { id: 'HKD', name: 'Hong Kong Dollar' }, + { id: 'AUD', name: 'Australian Dollar' }, + { id: 'SGD', name: 'Singapore Dollar' }, + { id: 'CHF', name: 'Swiss Franc' }, + { id: 'SEK', name: 'Swedish Krona' }, + { id: 'PLN', name: 'Polish Złoty' }, + { id: 'NOK', name: 'Norwegian Krone' }, + { id: 'DKK', name: 'Danish Krone' }, + { id: 'NZD', name: 'New Zealand Dollar' }, + { id: 'ZAR', name: 'South African Rand' }, + { id: 'MXN', name: 'Mexican Peso' }, + { id: 'THB', name: 'Thai Baht' }, + { id: 'HUF', name: 'Hungarian Forint' }, + { id: 'MYR', name: 'Malaysian Ringgit' }, + { id: 'INR', name: 'Indian Rupee' }, + { id: 'KRW', name: 'South Korean Won' }, + { id: 'BRL', name: 'Brazilian Real' }, + { id: 'TRY', name: 'Turkish Lira' }, + { id: 'CZK', name: 'Czech Koruna' }, + { id: 'ILS', name: 'Israeli New Shekel' }, + { id: 'RUB', name: 'Russian Ruble' }, + { id: 'AED', name: 'United Arab Emirates Dirham' }, + { id: 'IDR', name: 'Indonesian Rupiah' }, + { id: 'PHP', name: 'Philippine Peso' }, + { id: 'RON', name: 'Romanian Leu' }, + { id: 'COP', name: 'Colombian Peso' }, + { id: 'SAR', name: 'Saudi Riyal' }, + { id: 'ARS', name: 'Argentine Peso' }, + { id: 'VND', name: 'Vietnamese Dong' }, + { id: 'CLP', name: 'Chilean Peso' }, + { id: 'EGP', name: 'Egyptian Pound' }, + { id: 'KWD', name: 'Kuwaiti Dinar' }, + { id: 'PKR', name: 'Pakistani Rupee' }, + { id: 'QAR', name: 'Qatari Riyal' }, + { id: 'BHD', name: 'Bahraini Dinar' }, + { id: 'UAH', name: 'Ukrainian Hryvnia' }, + { id: 'PEN', name: 'Peruvian Sol' }, + { id: 'BDT', name: 'Bangladeshi Taka' }, + { id: 'MAD', name: 'Moroccan Dirham' }, + { id: 'KES', name: 'Kenyan Shilling' }, + { id: 'NGN', name: 'Nigerian Naira' }, + { id: 'TND', name: 'Tunisian Dinar' }, + { id: 'OMR', name: 'Omani Rial' }, + { id: 'GHS', name: 'Ghanaian Cedi' }, +]; + +export const TIMEZONE_LEGACY: Record<string, string> = { + 'Asia/Batavia': 'Asia/Jakarta', + 'Asia/Calcutta': 'Asia/Kolkata', + 'Asia/Chongqing': 'Asia/Shanghai', + 'Asia/Harbin': 'Asia/Shanghai', + 'Asia/Jayapura': 'Asia/Pontianak', + 'Asia/Katmandu': 'Asia/Kathmandu', + 'Asia/Macao': 'Asia/Macau', + 'Asia/Rangoon': 'Asia/Yangon', + 'Asia/Saigon': 'Asia/Ho_Chi_Minh', + 'Europe/Kiev': 'Europe/Kyiv', + 'Europe/Zaporozhye': 'Europe/Kyiv', + 'Etc/UTC': 'UTC', + 'US/Arizona': 'America/Phoenix', + 'US/Central': 'America/Chicago', + 'US/Eastern': 'America/New_York', + 'US/Mountain': 'America/Denver', + 'US/Pacific': 'America/Los_Angeles', + 'US/Samoa': 'Pacific/Pago_Pago', +}; diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..a6d912b --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,65 @@ +import crypto from 'node:crypto'; +import { v4, v5, v7 } from 'uuid'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const SALT_LENGTH = 64; +const TAG_LENGTH = 16; +const TAG_POSITION = SALT_LENGTH + IV_LENGTH; +const ENC_POSITION = TAG_POSITION + TAG_LENGTH; + +const HASH_ALGO = 'sha512'; +const HASH_ENCODING = 'hex'; + +const getKey = (password: string, salt: Buffer) => + crypto.pbkdf2Sync(password, salt, 10000, 32, 'sha512'); + +export function encrypt(value: any, secret: any) { + const iv = crypto.randomBytes(IV_LENGTH); + const salt = crypto.randomBytes(SALT_LENGTH); + const key = getKey(secret, salt); + + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([cipher.update(String(value), 'utf8'), cipher.final()]); + + const tag = cipher.getAuthTag(); + + return Buffer.concat([salt, iv, tag, encrypted]).toString('base64'); +} + +export function decrypt(value: any, secret: any) { + const str = Buffer.from(String(value), 'base64'); + const salt = str.subarray(0, SALT_LENGTH); + const iv = str.subarray(SALT_LENGTH, TAG_POSITION); + const tag = str.subarray(TAG_POSITION, ENC_POSITION); + const encrypted = str.subarray(ENC_POSITION); + + const key = getKey(secret, salt); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + + decipher.setAuthTag(tag); + + return decipher.update(encrypted) + decipher.final('utf8'); +} + +export function hash(...args: string[]) { + return crypto.createHash(HASH_ALGO).update(args.join('')).digest(HASH_ENCODING); +} + +export function md5(...args: string[]) { + return crypto.createHash('md5').update(args.join('')).digest('hex'); +} + +export function secret() { + return hash(process.env.APP_SECRET || process.env.DATABASE_URL); +} + +export function uuid(...args: any) { + if (args.length) { + return v5(hash(...args, secret()), v5.DNS); + } + + return process.env.USE_UUIDV7 ? v7() : v4(); +} diff --git a/src/lib/data.ts b/src/lib/data.ts new file mode 100644 index 0000000..fe69edf --- /dev/null +++ b/src/lib/data.ts @@ -0,0 +1,94 @@ +import { DATA_TYPE, DATETIME_REGEX } from './constants'; +import type { DynamicDataType } from './types'; + +export function flattenJSON( + eventData: Record<string, any>, + keyValues: { key: string; value: any; dataType: DynamicDataType }[] = [], + parentKey = '', +): { key: string; value: any; dataType: DynamicDataType }[] { + return Object.keys(eventData).reduce( + (acc, key) => { + const value = eventData[key]; + const type = typeof eventData[key]; + + // nested object + if (value && type === 'object' && !Array.isArray(value) && !isValidDateValue(value)) { + flattenJSON(value, acc.keyValues, getKeyName(key, parentKey)); + } else { + createKey(getKeyName(key, parentKey), value, acc); + } + + return acc; + }, + { keyValues, parentKey }, + ).keyValues; +} + +export function isValidDateValue(value: string) { + return typeof value === 'string' && DATETIME_REGEX.test(value); +} + +export function getDataType(value: any): string { + let type: string = typeof value; + + if (isValidDateValue(value)) { + type = 'date'; + } + + return type; +} + +export function getStringValue(value: string, dataType: number) { + if (dataType === DATA_TYPE.number) { + return parseFloat(value).toFixed(4); + } + + if (dataType === DATA_TYPE.date) { + return new Date(value).toISOString(); + } + + return value; +} + +function createKey(key: string, value: string, acc: { keyValues: any[]; parentKey: string }) { + const type = getDataType(value); + + let dataType = null; + + switch (type) { + case 'number': + dataType = DATA_TYPE.number; + break; + case 'string': + dataType = DATA_TYPE.string; + break; + case 'boolean': + dataType = DATA_TYPE.boolean; + value = value ? 'true' : 'false'; + break; + case 'date': + dataType = DATA_TYPE.date; + break; + case 'object': + dataType = DATA_TYPE.array; + value = JSON.stringify(value); + break; + default: + dataType = DATA_TYPE.string; + break; + } + + acc.keyValues.push({ key, value, dataType }); +} + +function getKeyName(key: string, parentKey: string) { + if (!parentKey) { + return key; + } + + return `${parentKey}.${key}`; +} + +export function objectToArray(obj: object) { + return Object.keys(obj).map(key => obj[key]); +} diff --git a/src/lib/date.ts b/src/lib/date.ts new file mode 100644 index 0000000..3c1fd1b --- /dev/null +++ b/src/lib/date.ts @@ -0,0 +1,375 @@ +import { + addDays, + addHours, + addMinutes, + addMonths, + addWeeks, + addYears, + differenceInCalendarDays, + differenceInCalendarMonths, + differenceInCalendarWeeks, + differenceInCalendarYears, + differenceInHours, + differenceInMinutes, + endOfDay, + endOfHour, + endOfMinute, + endOfMonth, + endOfWeek, + endOfYear, + format, + isBefore, + isDate, + isEqual, + isSameDay, + max, + min, + startOfDay, + startOfHour, + startOfMinute, + startOfMonth, + startOfWeek, + startOfYear, + subDays, + subHours, + subMinutes, + subMonths, + subWeeks, + subYears, +} from 'date-fns'; +import { utcToZonedTime } from 'date-fns-tz'; +import { getDateLocale } from '@/lib/lang'; +import type { DateRange } from '@/lib/types'; + +export const TIME_UNIT = { + minute: 'minute', + hour: 'hour', + day: 'day', + week: 'week', + month: 'month', + year: 'year', +}; + +export const DATE_FUNCTIONS = { + minute: { + diff: differenceInMinutes, + add: addMinutes, + sub: subMinutes, + start: startOfMinute, + end: endOfMinute, + }, + hour: { + diff: differenceInHours, + add: addHours, + sub: subHours, + start: startOfHour, + end: endOfHour, + }, + day: { + diff: differenceInCalendarDays, + add: addDays, + sub: subDays, + start: startOfDay, + end: endOfDay, + }, + week: { + diff: differenceInCalendarWeeks, + add: addWeeks, + sub: subWeeks, + start: startOfWeek, + end: endOfWeek, + }, + month: { + diff: differenceInCalendarMonths, + add: addMonths, + sub: subMonths, + start: startOfMonth, + end: endOfMonth, + }, + year: { + diff: differenceInCalendarYears, + add: addYears, + sub: subYears, + start: startOfYear, + end: endOfYear, + }, +}; + +export const DATE_FORMATS = { + minute: 'yyyy-MM-dd HH:mm', + hour: 'yyyy-MM-dd HH', + day: 'yyyy-MM-dd', + week: "yyyy-'W'II", + month: 'yyyy-MM', + year: 'yyyy', +}; + +const TIMEZONE_MAPPINGS: Record<string, string> = { + 'Asia/Calcutta': 'Asia/Kolkata', +}; + +export function normalizeTimezone(timezone: string): string { + return TIMEZONE_MAPPINGS[timezone] || timezone; +} + +export function isValidTimezone(timezone: string) { + try { + const normalizedTimezone = normalizeTimezone(timezone); + Intl.DateTimeFormat(undefined, { timeZone: normalizedTimezone }); + return true; + } catch { + return false; + } +} + +export function getTimezone() { + return Intl.DateTimeFormat().resolvedOptions().timeZone; +} + +export function parseDateValue(value: string) { + const match = value.match?.(/^(?<num>[0-9-]+)(?<unit>hour|day|week|month|year)$/); + + if (!match) return null; + + const { num, unit } = match.groups; + + return { num: +num, unit }; +} + +export function parseDateRange(value: string, locale = 'en-US', timezone?: string): DateRange { + if (typeof value !== 'string') { + return null; + } + + if (value.startsWith('range')) { + const [, startTime, endTime] = value.split(':'); + + const startDate = new Date(+startTime); + const endDate = new Date(+endTime); + const unit = getMinimumUnit(startDate, endDate); + + return { + startDate, + endDate, + value, + ...parseDateValue(value), + unit, + }; + } + + const date = new Date(); + const now = timezone ? utcToZonedTime(date, timezone) : date; + const dateLocale = getDateLocale(locale); + const { num = 1, unit } = parseDateValue(value); + + switch (unit) { + case 'hour': + return { + startDate: num ? subHours(startOfHour(now), num) : startOfHour(now), + endDate: endOfHour(now), + offset: 0, + num: num || 1, + unit, + value, + }; + case 'day': + return { + startDate: num ? subDays(startOfDay(now), num) : startOfDay(now), + endDate: endOfDay(now), + unit: num ? 'day' : 'hour', + offset: 0, + num: num || 1, + value, + }; + case 'week': + return { + startDate: num + ? subWeeks(startOfWeek(now, { locale: dateLocale }), num) + : startOfWeek(now, { locale: dateLocale }), + endDate: endOfWeek(now, { locale: dateLocale }), + unit: 'day', + offset: 0, + num: num || 1, + value, + }; + case 'month': + return { + startDate: num ? subMonths(startOfMonth(now), num) : startOfMonth(now), + endDate: endOfMonth(now), + unit: num ? 'month' : 'day', + offset: 0, + num: num || 1, + value, + }; + case 'year': + return { + startDate: num ? subYears(startOfYear(now), num) : startOfYear(now), + endDate: endOfYear(now), + unit: 'month', + offset: 0, + num: num || 1, + value, + }; + } +} + +export function getOffsetDateRange(dateRange: DateRange, offset: number) { + if (offset === 0) { + return dateRange; + } + + const { startDate, endDate, unit, num, value } = dateRange; + + const change = num * offset; + const { add } = DATE_FUNCTIONS[unit]; + const { unit: originalUnit } = parseDateValue(value) || {}; + + switch (originalUnit) { + case 'day': + return { + ...dateRange, + offset, + startDate: addDays(startDate, change), + endDate: addDays(endDate, change), + }; + case 'week': + return { + ...dateRange, + offset, + startDate: addWeeks(startDate, change), + endDate: addWeeks(endDate, change), + }; + case 'month': + return { + ...dateRange, + offset, + startDate: addMonths(startDate, change), + endDate: addMonths(endDate, change), + }; + case 'year': + return { + ...dateRange, + offset, + startDate: addYears(startDate, change), + endDate: addYears(endDate, change), + }; + default: + return { + startDate: add(startDate, change), + endDate: add(endDate, change), + offset, + value, + unit, + num, + }; + } +} + +export function getAllowedUnits(startDate: Date, endDate: Date) { + const units = ['minute', 'hour', 'day', 'month', 'year']; + const minUnit = getMinimumUnit(startDate, endDate); + const index = units.indexOf(minUnit === 'year' ? 'month' : minUnit); + + return index >= 0 ? units.splice(index) : []; +} + +export function getMinimumUnit(startDate: number | Date, endDate: number | Date) { + if (differenceInMinutes(endDate, startDate) <= 60) { + return 'minute'; + } else if (differenceInHours(endDate, startDate) <= 48) { + return 'hour'; + } else if (differenceInCalendarMonths(endDate, startDate) <= 6) { + return 'day'; + } else if (differenceInCalendarMonths(endDate, startDate) <= 24) { + return 'month'; + } + + return 'year'; +} + +export function maxDate(...args: Date[]) { + return max(args.filter(n => isDate(n))); +} + +export function minDate(...args: any[]) { + return min(args.filter(n => isDate(n))); +} + +export function getCompareDate(compare: string, startDate: Date, endDate: Date) { + if (compare === 'yoy') { + return { compare, startDate: subYears(startDate, 1), endDate: subYears(endDate, 1) }; + } + + if (compare === 'prev') { + const diff = differenceInMinutes(endDate, startDate); + + return { compare, startDate: subMinutes(startDate, diff), endDate: subMinutes(endDate, diff) }; + } + + return {}; +} + +export function getDayOfWeekAsDate(dayOfWeek: number) { + const startOfWeekDay = startOfWeek(new Date()); + const daysToAdd = [0, 1, 2, 3, 4, 5, 6].indexOf(dayOfWeek); + let currentDate = addDays(startOfWeekDay, daysToAdd); + + // Ensure we're not returning a past date + if (isSameDay(currentDate, startOfWeekDay)) { + currentDate = addDays(currentDate, 7); + } + + return currentDate; +} + +export function formatDate( + date: string | number | Date, + dateFormat: string = 'PPpp', + locale = 'en-US', +) { + return format(typeof date === 'string' ? new Date(date) : date, dateFormat, { + locale: getDateLocale(locale), + }); +} + +export function generateTimeSeries( + data: { x: string; y: number; d?: string }[], + minDate: Date, + maxDate: Date, + unit: string, + locale: string, +) { + const add = DATE_FUNCTIONS[unit].add; + const start = DATE_FUNCTIONS[unit].start; + const fmt = DATE_FORMATS[unit]; + + let current = start(minDate); + const end = start(maxDate); + + const timeseries: string[] = []; + + while (isBefore(current, end) || isEqual(current, end)) { + timeseries.push(formatDate(current, fmt, locale)); + current = add(current, 1); + } + + const lookup = new Map(data.map(({ x, y, d }) => [formatDate(x, fmt, locale), { x, y, d }])); + + return timeseries.map(t => { + const { x, y, d } = lookup.get(t) || {}; + + return { x: t, d: d ?? x, y: y ?? null }; + }); +} + +export function getDateRangeValue(startDate: Date, endDate: Date) { + return `range:${startDate.getTime()}:${endDate.getTime()}`; +} + +export function getMonthDateRangeValue(date: Date) { + return getDateRangeValue(startOfMonth(date), endOfMonth(date)); +} + +export function isInvalidDate(date: any) { + return date instanceof Date && Number.isNaN(date.getTime()); +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..7b6e836 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,40 @@ +export const PRISMA = 'prisma'; +export const POSTGRESQL = 'postgresql'; +export const CLICKHOUSE = 'clickhouse'; +export const KAFKA = 'kafka'; +export const KAFKA_PRODUCER = 'kafka-producer'; + +// Fixes issue with converting bigint values +BigInt.prototype.toJSON = function () { + return Number(this); +}; + +export function getDatabaseType(url = process.env.DATABASE_URL) { + const type = url?.split(':')[0]; + + if (type === 'postgres') { + return POSTGRESQL; + } + + return type; +} + +export async function runQuery(queries: any) { + if (process.env.CLICKHOUSE_URL) { + if (queries[KAFKA]) { + return queries[KAFKA](); + } + + return queries[CLICKHOUSE](); + } + + const db = getDatabaseType(); + + if (db === POSTGRESQL) { + return queries[PRISMA](); + } +} + +export function notImplemented() { + throw new Error('Not implemented.'); +} diff --git a/src/lib/detect.ts b/src/lib/detect.ts new file mode 100644 index 0000000..68cb667 --- /dev/null +++ b/src/lib/detect.ts @@ -0,0 +1,154 @@ +import path from 'node:path'; +import { browserName, detectOS } from 'detect-browser'; +import ipaddr from 'ipaddr.js'; +import isLocalhost from 'is-localhost-ip'; +import maxmind from 'maxmind'; +import { UAParser } from 'ua-parser-js'; +import { getIpAddress, stripPort } from '@/lib/ip'; +import { safeDecodeURIComponent } from '@/lib/url'; + +const MAXMIND = 'maxmind'; + +const PROVIDER_HEADERS = [ + // Cloudflare headers + { + countryHeader: 'cf-ipcountry', + regionHeader: 'cf-region-code', + cityHeader: 'cf-ipcity', + }, + // Vercel headers + { + countryHeader: 'x-vercel-ip-country', + regionHeader: 'x-vercel-ip-country-region', + cityHeader: 'x-vercel-ip-city', + }, + // CloudFront headers + { + countryHeader: 'cloudfront-viewer-country', + regionHeader: 'cloudfront-viewer-country-region', + cityHeader: 'cloudfront-viewer-city', + }, +]; + +export function getDevice(userAgent: string, screen: string = '') { + const { device } = UAParser(userAgent); + + const [width] = screen.split('x'); + + const type = device?.type || 'desktop'; + + if (type === 'desktop' && screen && +width <= 1920) { + return 'laptop'; + } + + return type; +} + +function getRegionCode(country: string, region: string) { + if (!country || !region) { + return undefined; + } + + return region.includes('-') ? region : `${country}-${region}`; +} + +function decodeHeader(s: string | undefined | null): string | undefined | null { + if (s === undefined || s === null) { + return s; + } + + return Buffer.from(s, 'latin1').toString('utf-8'); +} + +export async function getLocation(ip: string = '', headers: Headers, hasPayloadIP: boolean) { + // Ignore local ips + if (!ip || (await isLocalhost(ip))) { + return null; + } + + if (!hasPayloadIP && !process.env.SKIP_LOCATION_HEADERS) { + for (const provider of PROVIDER_HEADERS) { + const countryHeader = headers.get(provider.countryHeader); + if (countryHeader) { + const country = decodeHeader(countryHeader); + const region = decodeHeader(headers.get(provider.regionHeader)); + const city = decodeHeader(headers.get(provider.cityHeader)); + + return { + country, + region: getRegionCode(country, region), + city, + }; + } + } + } + + // Database lookup + if (!globalThis[MAXMIND]) { + const dir = path.join(process.cwd(), 'geo'); + + globalThis[MAXMIND] = await maxmind.open( + process.env.GEOLITE_DB_PATH || path.resolve(dir, 'GeoLite2-City.mmdb'), + ); + } + + const result = globalThis[MAXMIND]?.get(stripPort(ip)); + + if (result) { + const country = result.country?.iso_code ?? result?.registered_country?.iso_code; + const region = result.subdivisions?.[0]?.iso_code; + const city = result.city?.names?.en; + + return { + country, + region: getRegionCode(country, region), + city, + }; + } +} + +export async function getClientInfo(request: Request, payload: Record<string, any>) { + const userAgent = payload?.userAgent || request.headers.get('user-agent'); + const ip = payload?.ip || getIpAddress(request.headers); + const location = await getLocation(ip, request.headers, !!payload?.ip); + const country = safeDecodeURIComponent(location?.country); + const region = safeDecodeURIComponent(location?.region); + const city = safeDecodeURIComponent(location?.city); + const browser = payload?.browser ?? browserName(userAgent); + const os = payload?.os ?? (detectOS(userAgent) as string); + const device = payload?.device ?? getDevice(userAgent, payload?.screen); + + return { userAgent, browser, os, ip, country, region, city, device }; +} + +export function hasBlockedIp(clientIp: string) { + const ignoreIps = process.env.IGNORE_IP; + + if (ignoreIps) { + const ips = []; + + if (ignoreIps) { + ips.push(...ignoreIps.split(',').map(n => n.trim())); + } + + return ips.find(ip => { + if (ip === clientIp) { + return true; + } + + // CIDR notation + if (ip.indexOf('/') > 0) { + const addr = ipaddr.parse(clientIp); + const range = ipaddr.parseCIDR(ip); + + if (addr.kind() === range[0].kind() && addr.match(range)) { + return true; + } + } + + return false; + }); + } + + return false; +} diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts new file mode 100644 index 0000000..1086973 --- /dev/null +++ b/src/lib/fetch.ts @@ -0,0 +1,58 @@ +import { buildPath } from '@/lib/url'; + +export interface ErrorResponse { + error: { + status: number; + message: string; + code?: string; + }; +} + +export interface FetchResponse { + ok: boolean; + status: number; + data?: any; + error?: ErrorResponse; +} + +export async function request( + method: string, + url: string, + body?: string, + headers: object = {}, +): Promise<FetchResponse> { + return fetch(url, { + method, + cache: 'no-cache', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...headers, + }, + body, + }).then(async res => { + const data = await res.json(); + + return { + ok: res.ok, + status: res.status, + data, + }; + }); +} + +export async function httpGet(path: string, params: object = {}, headers: object = {}) { + return request('GET', buildPath(path, params), undefined, headers); +} + +export async function httpDelete(path: string, params: object = {}, headers: object = {}) { + return request('DELETE', buildPath(path, params), undefined, headers); +} + +export async function httpPost(path: string, params: object = {}, headers: object = {}) { + return request('POST', path, JSON.stringify(params), headers); +} + +export async function httpPut(path: string, params: object = {}, headers: object = {}) { + return request('PUT', path, JSON.stringify(params), headers); +} diff --git a/src/lib/filters.ts b/src/lib/filters.ts new file mode 100644 index 0000000..3da268d --- /dev/null +++ b/src/lib/filters.ts @@ -0,0 +1,31 @@ +export const percentFilter = (data: any[]) => { + if (!Array.isArray(data)) return []; + const total = data.reduce((n, { y }) => n + y, 0); + return data.map(({ x, y, ...props }) => ({ x, y, z: total ? (y / total) * 100 : 0, ...props })); +}; + +export const paramFilter = (data: any[]) => { + const map = data.reduce((obj, { x, y }) => { + try { + const searchParams = new URLSearchParams(x); + + for (const [key, value] of searchParams) { + if (!obj[key]) { + obj[key] = { [value]: y }; + } else if (!obj[key][value]) { + obj[key][value] = y; + } else { + obj[key][value] += y; + } + } + } catch { + // Ignore + } + + return obj; + }, {}); + + return Object.keys(map).flatMap(key => + Object.keys(map[key]).map(n => ({ x: `${key}=${n}`, p: key, v: n, y: map[key][n] })), + ); +}; diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..52fd304 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,118 @@ +export function parseTime(val: number) { + const days = ~~(val / 86400); + const hours = ~~(val / 3600) - days * 24; + const minutes = ~~(val / 60) - days * 1440 - hours * 60; + const seconds = ~~val - days * 86400 - hours * 3600 - minutes * 60; + const ms = (val - ~~val) * 1000; + + return { + days, + hours, + minutes, + seconds, + ms, + }; +} + +export function formatTime(val: number) { + const { hours, minutes, seconds } = parseTime(val); + const h = hours > 0 ? `${hours}:` : ''; + const m = hours > 0 ? minutes.toString().padStart(2, '0') : minutes; + const s = seconds.toString().padStart(2, '0'); + + return `${h}${m}:${s}`; +} + +export function formatShortTime(val: number, formats = ['m', 's'], space = '') { + const { days, hours, minutes, seconds, ms } = parseTime(val); + let t = ''; + + if (days > 0 && formats.indexOf('d') !== -1) t += `${days}d${space}`; + if (hours > 0 && formats.indexOf('h') !== -1) t += `${hours}h${space}`; + if (minutes > 0 && formats.indexOf('m') !== -1) t += `${minutes}m${space}`; + if (seconds > 0 && formats.indexOf('s') !== -1) t += `${seconds}s${space}`; + if (ms > 0 && formats.indexOf('ms') !== -1) t += `${ms}ms`; + + if (!t) { + return `0${formats[formats.length - 1]}`; + } + + return t; +} + +export function formatNumber(n: string | number) { + return Number(n).toFixed(0); +} + +export function formatLongNumber(value: number) { + const n = Number(value); + + if (n >= 1000000000) { + return `${(n / 1000000).toFixed(1)}b`; + } + if (n >= 1000000) { + return `${(n / 1000000).toFixed(1)}m`; + } + if (n >= 100000) { + return `${(n / 1000).toFixed(0)}k`; + } + if (n >= 10000) { + return `${(n / 1000).toFixed(1)}k`; + } + if (n >= 1000) { + return `${(n / 1000).toFixed(2)}k`; + } + + return formatNumber(n); +} + +export function stringToColor(str: string) { + if (!str) { + return '#ffffff'; + } + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let color = '#'; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += `00${value.toString(16)}`.slice(-2); + } + return color; +} + +export function formatCurrency(value: number, currency: string, locale = 'en-US') { + let formattedValue: Intl.NumberFormat; + + try { + formattedValue = new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + }); + } catch { + // Fallback to default currency format if an error occurs + formattedValue = new Intl.NumberFormat(locale, { + style: 'currency', + currency: 'USD', + }); + } + + return formattedValue.format(value); +} + +export function formatLongCurrency(value: number, currency: string, locale = 'en-US') { + const n = Number(value); + + if (n >= 1000000000) { + return `${formatCurrency(n / 1000000000, currency, locale)}b`; + } + if (n >= 1000000) { + return `${formatCurrency(n / 1000000, currency, locale)}m`; + } + if (n >= 1000) { + return `${formatCurrency(n / 1000, currency, locale)}k`; + } + + return formatCurrency(n, currency, locale); +} diff --git a/src/lib/generate.ts b/src/lib/generate.ts new file mode 100644 index 0000000..8e25aa0 --- /dev/null +++ b/src/lib/generate.ts @@ -0,0 +1,20 @@ +import prand from 'pure-rand'; + +const seed = Date.now() ^ (Math.random() * 0x100000000); +const rng = prand.xoroshiro128plus(seed); + +export function random(min: number, max: number) { + return prand.unsafeUniformIntDistribution(min, max, rng); +} + +export function getRandomChars( + n: number, + chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', +) { + const arr = chars.split(''); + let s = ''; + for (let i = 0; i < n; i++) { + s += arr[random(0, arr.length - 1)]; + } + return s; +} diff --git a/src/lib/ip.ts b/src/lib/ip.ts new file mode 100644 index 0000000..5cd7757 --- /dev/null +++ b/src/lib/ip.ts @@ -0,0 +1,60 @@ +export const IP_ADDRESS_HEADERS = [ + 'true-client-ip', // CDN + 'cf-connecting-ip', // Cloudflare + 'fastly-client-ip', // Fastly + 'x-nf-client-connection-ip', // Netlify + 'do-connecting-ip', // Digital Ocean + 'x-real-ip', // Reverse proxy + 'x-appengine-user-ip', // Google App Engine + 'x-forwarded-for', + 'forwarded', + 'x-client-ip', + 'x-cluster-client-ip', + 'x-forwarded', +]; + +export function getIpAddress(headers: Headers) { + const customHeader = process.env.CLIENT_IP_HEADER; + + if (customHeader && headers.get(customHeader)) { + return headers.get(customHeader); + } + + const header = IP_ADDRESS_HEADERS.find(name => { + return headers.get(name); + }); + + const ip = headers.get(header); + + if (header === 'x-forwarded-for') { + return ip?.split(',')?.[0]?.trim(); + } + + if (header === 'forwarded') { + const match = ip.match(/for=(\[?[0-9a-fA-F:.]+\]?)/); + + if (match) { + return match[1]; + } + } + + return ip; +} + +export function stripPort(ip: string) { + if (ip.startsWith('[')) { + const endBracket = ip.indexOf(']'); + if (endBracket !== -1) { + return ip.slice(0, endBracket + 1); + } + } + + const idx = ip.lastIndexOf(':'); + if (idx !== -1) { + if (ip.includes('.') || /^[a-zA-Z0-9.-]+$/.test(ip.slice(0, idx))) { + return ip.slice(0, idx); + } + } + + return ip; +} diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts new file mode 100644 index 0000000..470c48f --- /dev/null +++ b/src/lib/jwt.ts @@ -0,0 +1,36 @@ +import jwt from 'jsonwebtoken'; +import { decrypt, encrypt } from '@/lib/crypto'; + +export function createToken(payload: any, secret: any, options?: any) { + return jwt.sign(payload, secret, options); +} + +export function parseToken(token: string, secret: any) { + try { + return jwt.verify(token, secret); + } catch { + return null; + } +} + +export function createSecureToken(payload: any, secret: any, options?: any) { + return encrypt(createToken(payload, secret, options), secret); +} + +export function parseSecureToken(token: string, secret: any) { + try { + return jwt.verify(decrypt(token, secret), secret); + } catch { + return null; + } +} + +export async function parseAuthToken(req: Request, secret: string) { + try { + const token = req.headers.get('authorization')?.split(' ')?.[1]; + + return parseSecureToken(token as string, secret); + } catch { + return null; + } +} diff --git a/src/lib/kafka.ts b/src/lib/kafka.ts new file mode 100644 index 0000000..1d60e1f --- /dev/null +++ b/src/lib/kafka.ts @@ -0,0 +1,112 @@ +import type * as tls from 'node:tls'; +import debug from 'debug'; +import { Kafka, logLevel, type Producer, type RecordMetadata, type SASLOptions } from 'kafkajs'; +import { serializeError } from 'serialize-error'; +import { KAFKA, KAFKA_PRODUCER } from '@/lib/db'; + +const log = debug('umami:kafka'); +const CONNECT_TIMEOUT = 5000; +const SEND_TIMEOUT = 3000; +const ACKS = 1; + +let kafka: Kafka; +let producer: Producer; +const enabled = Boolean(process.env.KAFKA_URL && process.env.KAFKA_BROKER); + +function getClient() { + const { username, password } = new URL(process.env.KAFKA_URL); + const brokers = process.env.KAFKA_BROKER.split(','); + const mechanism = + (process.env.KAFKA_SASL_MECHANISM as 'plain' | 'scram-sha-256' | 'scram-sha-512') || 'plain'; + + const ssl: { ssl?: tls.ConnectionOptions | boolean; sasl?: SASLOptions } = + username && password + ? { + ssl: { + rejectUnauthorized: false, + }, + sasl: { + mechanism, + username, + password, + }, + } + : {}; + + const client: Kafka = new Kafka({ + clientId: 'umami', + brokers: brokers, + connectionTimeout: CONNECT_TIMEOUT, + logLevel: logLevel.ERROR, + ...ssl, + }); + + if (process.env.NODE_ENV !== 'production') { + globalThis[KAFKA] = client; + } + + log('Kafka initialized'); + + return client; +} + +async function getProducer(): Promise<Producer> { + const producer = kafka.producer(); + await producer.connect(); + + if (process.env.NODE_ENV !== 'production') { + globalThis[KAFKA_PRODUCER] = producer; + } + + log('Kafka producer initialized'); + + return producer; +} + +async function sendMessage( + topic: string, + message: Record<string, string | number> | Record<string, string | number>[], +): Promise<RecordMetadata[]> { + try { + await connect(); + + return producer.send({ + topic, + messages: Array.isArray(message) + ? message.map(a => { + return { value: JSON.stringify(a) }; + }) + : [ + { + value: JSON.stringify(message), + }, + ], + timeout: SEND_TIMEOUT, + acks: ACKS, + }); + } catch (e) { + // eslint-disable-next-line no-console + console.log('KAFKA ERROR:', serializeError(e)); + } +} + +async function connect(): Promise<Kafka> { + if (!kafka) { + kafka = process.env.KAFKA_URL && process.env.KAFKA_BROKER && (globalThis[KAFKA] || getClient()); + + if (kafka) { + producer = globalThis[KAFKA_PRODUCER] || (await getProducer()); + } + } + + return kafka; +} + +export default { + enabled, + client: kafka, + producer, + log, + connect, + sendMessage, +}; diff --git a/src/lib/lang.ts b/src/lib/lang.ts new file mode 100644 index 0000000..f874640 --- /dev/null +++ b/src/lib/lang.ts @@ -0,0 +1,111 @@ +import { + arSA, + be, + bg, + bn, + bs, + ca, + cs, + da, + de, + el, + enGB, + enUS, + es, + faIR, + fi, + fr, + he, + hi, + hr, + hu, + id, + it, + ja, + km, + ko, + lt, + mn, + ms, + nb, + nl, + pl, + pt, + ptBR, + ro, + ru, + sk, + sl, + sv, + ta, + th, + tr, + uk, + uz, + vi, + zhCN, + zhTW, +} from 'date-fns/locale'; + +export const languages = { + 'ar-SA': { label: 'العربية', dateLocale: arSA, dir: 'rtl' }, + 'be-BY': { label: 'Беларуская', dateLocale: be }, + 'bg-BG': { label: 'български език', dateLocale: bg }, + 'bn-BD': { label: 'বাংলা', dateLocale: bn }, + 'bs-BA': { label: 'Bosanski', dateLocale: bs }, + 'ca-ES': { label: 'Català', dateLocale: ca }, + 'cs-CZ': { label: 'Čeština', dateLocale: cs }, + 'da-DK': { label: 'Dansk', dateLocale: da }, + 'de-CH': { label: 'Schwiizerdütsch', dateLocale: de }, + 'de-DE': { label: 'Deutsch', dateLocale: de }, + 'el-GR': { label: 'Ελληνικά', dateLocale: el }, + 'en-GB': { label: 'English (UK)', dateLocale: enGB }, + 'en-US': { label: 'English (US)', dateLocale: enUS }, + 'es-ES': { label: 'Español', dateLocale: es }, + 'fa-IR': { label: 'فارسی', dateLocale: faIR, dir: 'rtl' }, + 'fi-FI': { label: 'Suomi', dateLocale: fi }, + 'fo-FO': { label: 'Føroyskt' }, + 'fr-FR': { label: 'Français', dateLocale: fr }, + 'ga-ES': { label: 'Galacian (Spain)', dateLocale: es }, + 'he-IL': { label: 'עברית', dateLocale: he }, + 'hi-IN': { label: 'हिन्दी', dateLocale: hi }, + 'hr-HR': { label: 'Hrvatski', dateLocale: hr }, + 'hu-HU': { label: 'Hungarian', dateLocale: hu }, + 'id-ID': { label: 'Bahasa Indonesia', dateLocale: id }, + 'it-IT': { label: 'Italiano', dateLocale: it }, + 'ja-JP': { label: '日本語', dateLocale: ja }, + 'km-KH': { label: 'ភាសាខ្មែរ', dateLocale: km }, + 'ko-KR': { label: '한국어', dateLocale: ko }, + 'lt-LT': { label: 'Lietuvių', dateLocale: lt }, + 'mn-MN': { label: 'Монгол', dateLocale: mn }, + 'ms-MY': { label: 'Malay', dateLocale: ms }, + 'my-MM': { label: 'မြန်မာဘာသာ', dateLocale: enUS }, + 'nl-NL': { label: 'Nederlands', dateLocale: nl }, + 'nb-NO': { label: 'Norsk Bokmål', dateLocale: nb }, + 'pl-PL': { label: 'Polski', dateLocale: pl }, + 'pt-BR': { label: 'Português do Brasil', dateLocale: ptBR }, + 'pt-PT': { label: 'Português', dateLocale: pt }, + 'ro-RO': { label: 'Română', dateLocale: ro }, + 'ru-RU': { label: 'Русский', dateLocale: ru }, + 'si-LK': { label: 'සිංහල', dateLocale: id }, + 'sk-SK': { label: 'Slovenčina', dateLocale: sk }, + 'sl-SI': { label: 'Slovenščina', dateLocale: sl }, + 'sv-SE': { label: 'Svenska', dateLocale: sv }, + 'ta-IN': { label: 'தமிழ்', dateLocale: ta }, + 'th-TH': { label: 'ภาษาไทย', dateLocale: th }, + 'tr-TR': { label: 'Türkçe', dateLocale: tr }, + 'uk-UA': { label: 'українська', dateLocale: uk }, + 'ur-PK': { label: 'Urdu (Pakistan)', dateLocale: uk, dir: 'rtl' }, + 'uz-UZ': { label: 'O‘zbekcha', dateLocale: uz }, + 'vi-VN': { label: 'Tiếng Việt', dateLocale: vi }, + 'zh-CN': { label: '中文', dateLocale: zhCN }, + 'zh-TW': { label: '中文(繁體)', dateLocale: zhTW }, +}; + +export function getDateLocale(locale: string) { + return languages[locale]?.dateLocale || enUS; +} + +export function getTextDirection(locale: string) { + return languages[locale]?.dir || 'ltr'; +} diff --git a/src/lib/load.ts b/src/lib/load.ts new file mode 100644 index 0000000..d4d6c3c --- /dev/null +++ b/src/lib/load.ts @@ -0,0 +1,40 @@ +import type { Session, Website } from '@/generated/prisma/client'; +import redis from '@/lib/redis'; +import { getWebsite } from '@/queries/prisma'; +import { getWebsiteSession } from '@/queries/sql'; + +export async function fetchWebsite(websiteId: string): Promise<Website> { + let website = null; + + if (redis.enabled) { + website = await redis.client.fetch(`website:${websiteId}`, () => getWebsite(websiteId), 86400); + } else { + website = await getWebsite(websiteId); + } + + if (!website || website.deletedAt) { + return null; + } + + return website; +} + +export async function fetchSession(websiteId: string, sessionId: string): Promise<Session> { + let session = null; + + if (redis.enabled) { + session = await redis.client.fetch( + `session:${sessionId}`, + () => getWebsiteSession(websiteId, sessionId), + 86400, + ); + } else { + session = await getWebsiteSession(websiteId, sessionId); + } + + if (!session) { + return null; + } + + return session; +} diff --git a/src/lib/params.ts b/src/lib/params.ts new file mode 100644 index 0000000..ab2d586 --- /dev/null +++ b/src/lib/params.ts @@ -0,0 +1,62 @@ +import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants'; +import type { Filter, QueryFilters, QueryOptions } from '@/lib/types'; + +export function parseFilterValue(param: any) { + if (typeof param === 'string') { + const operatorValues = Object.values(OPERATORS).join('|'); + + const regex = new RegExp(`^(${operatorValues})\\.(.*)$`); + + const [, operator, value] = param.match(regex) || []; + + return { operator: operator || OPERATORS.equals, value: value || param }; + } + + return { operator: OPERATORS.equals, value: param }; +} + +export function isEqualsOperator(operator: any) { + return [OPERATORS.equals, OPERATORS.notEquals].includes(operator); +} + +export function isSearchOperator(operator: any) { + return [OPERATORS.contains, OPERATORS.doesNotContain].includes(operator); +} + +export function filtersObjectToArray(filters: QueryFilters, options: QueryOptions = {}): Filter[] { + if (!filters) { + return []; + } + + return Object.keys(filters).reduce((arr, key) => { + const filter = filters[key]; + + if (filter === undefined || filter === null) { + return arr; + } + + if (filter?.name && filter?.value !== undefined) { + return arr.concat({ ...filter, column: options?.columns?.[key] ?? FILTER_COLUMNS[key] }); + } + + const { operator, value } = parseFilterValue(filter); + + return arr.concat({ + name: key, + column: options?.columns?.[key] ?? FILTER_COLUMNS[key], + operator, + value, + prefix: options?.prefix, + }); + }, []); +} + +export function filtersArrayToObject(filters: Filter[]) { + return filters.reduce((obj, filter: Filter) => { + const { name, operator, value } = filter; + + obj[name] = `${operator}.${value}`; + + return obj; + }, {}); +} diff --git a/src/lib/password.ts b/src/lib/password.ts new file mode 100644 index 0000000..f5c450b --- /dev/null +++ b/src/lib/password.ts @@ -0,0 +1,11 @@ +import bcrypt from 'bcryptjs'; + +const SALT_ROUNDS = 10; + +export function hashPassword(password: string, rounds = SALT_ROUNDS) { + return bcrypt.hashSync(password, rounds); +} + +export function checkPassword(password: string, passwordHash: string) { + return bcrypt.compareSync(password, passwordHash); +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..64cb870 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,368 @@ +import { PrismaPg } from '@prisma/adapter-pg'; +import { readReplicas } from '@prisma/extension-read-replicas'; +import debug from 'debug'; +import { PrismaClient } from '@/generated/prisma/client'; +import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS, OPERATORS, SESSION_COLUMNS } from './constants'; +import { filtersObjectToArray } from './params'; +import type { Operator, QueryFilters, QueryOptions } from './types'; + +const log = debug('umami:prisma'); + +const PRISMA = 'prisma'; + +const PRISMA_LOG_OPTIONS = { + log: [ + { + emit: 'event' as const, + level: 'query' as const, + }, + ], +}; + +const DATE_FORMATS = { + minute: 'YYYY-MM-DD HH24:MI:00', + hour: 'YYYY-MM-DD HH24:00:00', + day: 'YYYY-MM-DD HH24:00:00', + month: 'YYYY-MM-01 HH24:00:00', + year: 'YYYY-01-01 HH24:00:00', +}; + +const DATE_FORMATS_UTC = { + minute: 'YYYY-MM-DD"T"HH24:MI:00"Z"', + hour: 'YYYY-MM-DD"T"HH24:00:00"Z"', + day: 'YYYY-MM-DD"T"HH24:00:00"Z"', + month: 'YYYY-MM-01"T"HH24:00:00"Z"', + year: 'YYYY-01-01"T"HH24:00:00"Z"', +}; + +function getAddIntervalQuery(field: string, interval: string): string { + return `${field} + interval '${interval}'`; +} + +function getDayDiffQuery(field1: string, field2: string): string { + return `${field1}::date - ${field2}::date`; +} + +function getCastColumnQuery(field: string, type: string): string { + return `${field}::${type}`; +} + +function getDateSQL(field: string, unit: string, timezone?: string): string { + if (timezone && timezone !== 'utc') { + return `to_char(date_trunc('${unit}', ${field} at time zone '${timezone}'), '${DATE_FORMATS[unit]}')`; + } + + return `to_char(date_trunc('${unit}', ${field}), '${DATE_FORMATS_UTC[unit]}')`; +} + +function getDateWeeklySQL(field: string, timezone?: string) { + return `concat(extract(dow from (${field} at time zone '${timezone}')), ':', to_char((${field} at time zone '${timezone}'), 'HH24'))`; +} + +export function getTimestampSQL(field: string) { + return `floor(extract(epoch from ${field}))`; +} + +function getTimestampDiffSQL(field1: string, field2: string): string { + return `floor(extract(epoch from (${field2} - ${field1})))`; +} + +function getSearchSQL(column: string, param: string = 'search'): string { + return `and ${column} ilike {{${param}}}`; +} + +function mapFilter(column: string, operator: string, name: string, type: string = '') { + const value = `{{${name}${type ? `::${type}` : ''}}}`; + + switch (operator) { + case OPERATORS.equals: + return `${column} = ${value}`; + case OPERATORS.notEquals: + return `${column} != ${value}`; + case OPERATORS.contains: + return `${column} ilike ${value}`; + case OPERATORS.doesNotContain: + return `${column} not ilike ${value}`; + default: + return ''; + } +} + +function getFilterQuery(filters: Record<string, any>, options: QueryOptions = {}): string { + const query = filtersObjectToArray(filters, options).reduce( + (arr, { name, column, operator, prefix = '' }) => { + const isCohort = options?.isCohort; + + if (isCohort) { + column = FILTER_COLUMNS[name.slice('cohort_'.length)]; + } + + if (column) { + arr.push(`and ${mapFilter(`${prefix}${column}`, operator, name)}`); + + if (name === 'referrer') { + arr.push( + `and (website_event.referrer_domain != website_event.hostname or website_event.referrer_domain is null)`, + ); + } + } + + return arr; + }, + [], + ); + + return query.join('\n'); +} + +function getCohortQuery(filters: QueryFilters = {}) { + if (!filters || Object.keys(filters).length === 0) { + return ''; + } + + const filterQuery = getFilterQuery(filters, { isCohort: true }); + + return `join + (select distinct website_event.session_id + from website_event + join session on session.session_id = website_event.session_id + and session.website_id = website_event.website_id + where website_event.website_id = {{websiteId}} + and website_event.created_at between {{cohort_startDate}} and {{cohort_endDate}} + ${filterQuery} + ) cohort + on cohort.session_id = website_event.session_id + `; +} + +function getDateQuery(filters: Record<string, any>) { + const { startDate, endDate } = filters; + + if (startDate) { + if (endDate) { + return `and website_event.created_at between {{startDate}} and {{endDate}}`; + } else { + return `and website_event.created_at >= {{startDate}}`; + } + } + + return ''; +} + +function getQueryParams(filters: Record<string, any>) { + return { + ...filters, + ...filtersObjectToArray(filters).reduce((obj, { name, operator, value }) => { + obj[name] = ([OPERATORS.contains, OPERATORS.doesNotContain] as Operator[]).includes(operator) + ? `%${value}%` + : value; + + return obj; + }, {}), + }; +} + +function parseFilters(filters: Record<string, any>, options?: QueryOptions) { + const joinSession = Object.keys(filters).find(key => + ['referrer', ...SESSION_COLUMNS].includes(key), + ); + + const cohortFilters = Object.fromEntries( + Object.entries(filters).filter(([key]) => key.startsWith('cohort_')), + ); + + return { + joinSessionQuery: + options?.joinSession || joinSession + ? `inner join session on website_event.session_id = session.session_id and website_event.website_id = session.website_id` + : '', + dateQuery: getDateQuery(filters), + filterQuery: getFilterQuery(filters, options), + queryParams: getQueryParams(filters), + cohortQuery: getCohortQuery(cohortFilters), + }; +} + +async function rawQuery(sql: string, data: Record<string, any>, name?: string): Promise<any> { + if (process.env.LOG_QUERY) { + log('QUERY:\n', sql); + log('PARAMETERS:\n', data); + log('NAME:\n', name); + } + const params = []; + const schema = getSchema(); + + if (schema) { + await client.$executeRawUnsafe(`SET search_path TO "${schema}";`); + } + + const query = sql?.replaceAll(/\{\{\s*(\w+)(::\w+)?\s*}}/g, (...args) => { + const [, name, type] = args; + + const value = data[name]; + + params.push(value); + + return `$${params.length}${type ?? ''}`; + }); + + if (process.env.DATABASE_REPLICA_URL && '$replica' in client) { + return client.$replica().$queryRawUnsafe(query, ...params); + } + + return client.$queryRawUnsafe(query, ...params); +} + +async function pagedQuery<T>(model: string, criteria: T, filters?: QueryFilters) { + const { page = 1, pageSize, orderBy, sortDescending = false, search } = filters || {}; + const size = +pageSize || DEFAULT_PAGE_SIZE; + + const data = await client[model].findMany({ + ...criteria, + ...{ + ...(size > 0 && { take: +size, skip: +size * (+page - 1) }), + ...(orderBy && { + orderBy: [ + { + [orderBy]: sortDescending ? 'desc' : 'asc', + }, + ], + }), + }, + }); + + const count = await client[model].count({ where: (criteria as any).where }); + + return { data, count, page: +page, pageSize: size, orderBy, search }; +} + +async function pagedRawQuery( + query: string, + queryParams: Record<string, any>, + filters: QueryFilters, + name?: string, +) { + const { page = 1, pageSize, orderBy, sortDescending = false } = filters; + const size = +pageSize || DEFAULT_PAGE_SIZE; + const offset = +size * (+page - 1); + const direction = sortDescending ? 'desc' : 'asc'; + + const statements = [ + orderBy && `order by ${orderBy} ${direction}`, + +size > 0 && `limit ${+size} offset ${offset}`, + ] + .filter(n => n) + .join('\n'); + + const count = await rawQuery(`select count(*) as num from (${query}) t`, queryParams).then( + res => res[0].num, + ); + + const data = await rawQuery(`${query}${statements}`, queryParams, name); + + return { data, count, page: +page, pageSize: size, orderBy }; +} + +function getSearchParameters(query: string, filters: Record<string, any>[]) { + if (!query) return; + + const parseFilter = (filter: Record<string, any>) => { + const [[key, value]] = Object.entries(filter); + + return { + [key]: + typeof value === 'string' + ? { + [value]: query, + mode: 'insensitive', + } + : parseFilter(value), + }; + }; + + const params = filters.map(filter => parseFilter(filter)); + + return { + AND: { + OR: params, + }, + }; +} + +function transaction(input: any, options?: any) { + return client.$transaction(input, options); +} + +function getSchema() { + const connectionUrl = new URL(process.env.DATABASE_URL); + + return connectionUrl.searchParams.get('schema'); +} + +function getClient() { + const url = process.env.DATABASE_URL; + const replicaUrl = process.env.DATABASE_REPLICA_URL; + const logQuery = process.env.LOG_QUERY; + const schema = getSchema(); + + const baseAdapter = new PrismaPg({ connectionString: url }, { schema }); + + const baseClient = new PrismaClient({ + adapter: baseAdapter, + errorFormat: 'pretty', + ...(logQuery ? PRISMA_LOG_OPTIONS : {}), + }); + + if (logQuery) { + baseClient.$on('query', log); + } + + if (!replicaUrl) { + log('Prisma initialized'); + globalThis[PRISMA] ??= baseClient; + return baseClient; + } + + const replicaAdapter = new PrismaPg({ connectionString: replicaUrl }, { schema }); + + const replicaClient = new PrismaClient({ + adapter: replicaAdapter, + errorFormat: 'pretty', + ...(logQuery ? PRISMA_LOG_OPTIONS : {}), + }); + + if (logQuery) { + replicaClient.$on('query', log); + } + + const extended = baseClient.$extends( + readReplicas({ + replicas: [replicaClient], + }), + ); + + log('Prisma initialized (with replica)'); + globalThis[PRISMA] ??= extended; + + return extended; +} + +const client = (globalThis[PRISMA] || getClient()) as ReturnType<typeof getClient>; + +export default { + client, + transaction, + getAddIntervalQuery, + getCastColumnQuery, + getDayDiffQuery, + getDateSQL, + getDateWeeklySQL, + getFilterQuery, + getSearchParameters, + getTimestampDiffSQL, + getSearchSQL, + pagedQuery, + pagedRawQuery, + parseFilters, + rawQuery, +}; diff --git a/src/lib/react.ts b/src/lib/react.ts new file mode 100644 index 0000000..668cdf1 --- /dev/null +++ b/src/lib/react.ts @@ -0,0 +1,77 @@ +import { + Children, + cloneElement, + type FC, + Fragment, + isValidElement, + type ReactElement, + type ReactNode, +} from 'react'; + +export function getFragmentChildren(children: ReactNode) { + return (children as ReactElement)?.type === Fragment + ? (children as ReactElement).props.children + : children; +} + +export function isValidChild(child: ReactElement, types: FC | FC[]) { + if (!isValidElement(child)) { + return false; + } + return (Array.isArray(types) ? types : [types]).find(type => type === child.type); +} + +export function mapChildren( + children: ReactNode, + handler: (child: ReactElement, index: number) => any, +) { + return Children.map(getFragmentChildren(children) as ReactElement[], (child, index) => { + if (!child?.props) { + return null; + } + return handler(child, index); + }); +} + +export function cloneChildren( + children: ReactNode, + handler: (child: ReactElement, index: number) => any, + options?: { validChildren?: any[]; onlyRenderValid?: boolean }, +): ReactNode { + if (!children) { + return null; + } + + const { validChildren, onlyRenderValid = false } = options || {}; + + return mapChildren(children, (child, index) => { + const invalid = validChildren && !isValidChild(child as ReactElement, validChildren); + + if (onlyRenderValid && invalid) { + return null; + } + + if (!invalid && isValidElement(child)) { + return cloneElement(child, handler(child, index)); + } + + return child; + }); +} + +export function renderChildren( + children: ReactNode | ((item: any, index: number, array: any) => ReactNode), + items: any[], + handler: (child: ReactElement, index: number) => object | undefined, + options?: { validChildren?: any[]; onlyRenderValid?: boolean }, +): ReactNode { + if (typeof children === 'function' && items?.length > 0) { + return cloneChildren(items.map(children), handler, options); + } + + return cloneChildren(getFragmentChildren(children as ReactNode), handler, options); +} + +export function countChildren(children: ReactNode): number { + return Children.count(getFragmentChildren(children)); +} diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..edde3d6 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,18 @@ +import { UmamiRedisClient } from '@umami/redis-client'; + +const REDIS = 'redis'; +const enabled = !!process.env.REDIS_URL; + +function getClient() { + const redis = new UmamiRedisClient({ url: process.env.REDIS_URL }); + + if (process.env.NODE_ENV !== 'production') { + globalThis[REDIS] = redis; + } + + return redis; +} + +const client = globalThis[REDIS] || getClient(); + +export default { client, enabled }; diff --git a/src/lib/request.ts b/src/lib/request.ts new file mode 100644 index 0000000..42c4490 --- /dev/null +++ b/src/lib/request.ts @@ -0,0 +1,145 @@ +import { z } from 'zod'; +import { checkAuth } from '@/lib/auth'; +import { DEFAULT_PAGE_SIZE, FILTER_COLUMNS } from '@/lib/constants'; +import { getAllowedUnits, getMinimumUnit, maxDate, parseDateRange } from '@/lib/date'; +import { fetchWebsite } from '@/lib/load'; +import { filtersArrayToObject } from '@/lib/params'; +import { badRequest, unauthorized } from '@/lib/response'; +import type { QueryFilters } from '@/lib/types'; +import { getWebsiteSegment } from '@/queries/prisma'; + +export async function parseRequest( + request: Request, + schema?: any, + options?: { skipAuth: boolean }, +): Promise<any> { + const url = new URL(request.url); + let query = Object.fromEntries(url.searchParams); + let body = await getJsonBody(request); + let error: () => undefined | undefined; + let auth = null; + + if (schema) { + const isGet = request.method === 'GET'; + const result = schema.safeParse(isGet ? query : body); + + if (!result.success) { + error = () => badRequest(z.treeifyError(result.error)); + } else if (isGet) { + query = result.data; + } else { + body = result.data; + } + } + + if (!options?.skipAuth && !error) { + auth = await checkAuth(request); + + if (!auth) { + error = () => unauthorized(); + } + } + + return { url, query, body, auth, error }; +} + +export async function getJsonBody(request: Request) { + try { + return await request.clone().json(); + } catch { + return undefined; + } +} + +export function getRequestDateRange(query: Record<string, string>) { + const { startAt, endAt, unit, timezone } = query; + + const startDate = new Date(+startAt); + const endDate = new Date(+endAt); + + return { + startDate, + endDate, + timezone, + unit: getAllowedUnits(startDate, endDate).includes(unit) + ? unit + : getMinimumUnit(startDate, endDate), + }; +} + +export function getRequestFilters(query: Record<string, any>) { + const result: Record<string, any> = {}; + + for (const key of Object.keys(FILTER_COLUMNS)) { + const value = query[key]; + if (value !== undefined) { + result[key] = value; + } + } + + return result; +} + +export async function setWebsiteDate(websiteId: string, data: Record<string, any>) { + const website = await fetchWebsite(websiteId); + + if (website?.resetAt) { + data.startDate = maxDate(data.startDate, new Date(website?.resetAt)); + } + + return data; +} + +export async function getQueryFilters( + params: Record<string, any>, + websiteId?: string, +): Promise<QueryFilters> { + const dateRange = getRequestDateRange(params); + const filters = getRequestFilters(params); + + if (websiteId) { + await setWebsiteDate(websiteId, dateRange); + + if (params.segment) { + const segmentParams = (await getWebsiteSegment(websiteId, params.segment)) + ?.parameters as Record<string, any>; + + Object.assign(filters, filtersArrayToObject(segmentParams.filters)); + } + + if (params.cohort) { + const cohortParams = (await getWebsiteSegment(websiteId, params.cohort)) + ?.parameters as Record<string, any>; + + const { startDate, endDate } = parseDateRange(cohortParams.dateRange); + + const cohortFilters = cohortParams.filters.map(({ name, ...props }) => ({ + ...props, + name: `cohort_${name}`, + })); + + cohortFilters.push({ + name: `cohort_${cohortParams.action.type}`, + operator: 'eq', + value: cohortParams.action.value, + }); + + Object.assign(filters, { + ...filtersArrayToObject(cohortFilters), + cohort_startDate: startDate, + cohort_endDate: endDate, + }); + } + } + + return { + ...dateRange, + ...filters, + page: params?.page, + pageSize: params?.pageSize ? params?.pageSize || DEFAULT_PAGE_SIZE : undefined, + orderBy: params?.orderBy, + sortDescending: params?.sortDescending, + search: params?.search, + compare: params?.compare, + }; +} diff --git a/src/lib/response.ts b/src/lib/response.ts new file mode 100644 index 0000000..f1ad5c7 --- /dev/null +++ b/src/lib/response.ts @@ -0,0 +1,58 @@ +export function ok() { + return Response.json({ ok: true }); +} + +export function json(data: Record<string, any> = {}) { + return Response.json(data); +} + +export function badRequest(error?: Record<string, any>) { + return Response.json( + { + error: { message: 'Bad request', code: 'bad-request', status: 400, ...error }, + }, + { status: 400 }, + ); +} + +export function unauthorized(error?: Record<string, any>) { + return Response.json( + { + error: { + message: 'Unauthorized', + code: 'unauthorized', + status: 401, + ...error, + }, + }, + { status: 401 }, + ); +} + +export function forbidden(error?: Record<string, any>) { + return Response.json( + { error: { message: 'Forbidden', code: 'forbidden', status: 403, ...error } }, + { status: 403 }, + ); +} + +export function notFound(error?: Record<string, any>) { + return Response.json( + { error: { message: 'Not found', code: 'not-found', status: 404, ...error } }, + { status: 404 }, + ); +} + +export function serverError(error?: Record<string, any>) { + return Response.json( + { + error: { + message: 'Server error', + code: 'server-error', + status: 500, + ...error, + }, + }, + { status: 500 }, + ); +} diff --git a/src/lib/schema.ts b/src/lib/schema.ts new file mode 100644 index 0000000..38f7339 --- /dev/null +++ b/src/lib/schema.ts @@ -0,0 +1,232 @@ +import { z } from 'zod'; +import { isValidTimezone, normalizeTimezone } from '@/lib/date'; +import { UNIT_TYPES } from './constants'; + +export const timezoneParam = z + .string() + .refine((value: string) => isValidTimezone(value), { + message: 'Invalid timezone', + }) + .transform((value: string) => normalizeTimezone(value)); + +export const unitParam = z.string().refine(value => UNIT_TYPES.includes(value), { + message: 'Invalid unit', +}); + +export const dateRangeParams = { + startAt: z.coerce.number().optional(), + endAt: z.coerce.number().optional(), + startDate: z.coerce.date().optional(), + endDate: z.coerce.date().optional(), + timezone: timezoneParam.optional(), + unit: unitParam.optional(), + compare: z.string().optional(), +}; + +export const filterParams = { + path: z.string().optional(), + referrer: z.string().optional(), + title: z.string().optional(), + query: z.string().optional(), + os: z.string().optional(), + browser: z.string().optional(), + device: z.string().optional(), + country: z.string().optional(), + region: z.string().optional(), + city: z.string().optional(), + tag: z.string().optional(), + hostname: z.string().optional(), + language: z.string().optional(), + event: z.string().optional(), + segment: z.uuid().optional(), + cohort: z.uuid().optional(), + eventType: z.coerce.number().int().positive().optional(), +}; + +export const searchParams = { + search: z.string().optional(), +}; + +export const pagingParams = { + page: z.coerce.number().int().positive().optional(), + pageSize: z.coerce.number().int().positive().optional(), +}; + +export const sortingParams = { + orderBy: z.string().optional(), +}; + +export const userRoleParam = z.enum(['admin', 'user', 'view-only']); + +export const teamRoleParam = z.enum(['team-member', 'team-view-only', 'team-manager']); + +export const anyObjectParam = z.record(z.string(), z.any()); + +export const urlOrPathParam = z.string().refine( + value => { + try { + new URL(value, 'https://localhost'); + return true; + } catch { + return false; + } + }, + { + message: 'Invalid URL.', + }, +); + +export const fieldsParam = z.enum([ + 'path', + 'referrer', + 'title', + 'query', + 'os', + 'browser', + 'device', + 'country', + 'region', + 'city', + 'tag', + 'hostname', + 'language', + 'event', +]); + +export const reportTypeParam = z.enum([ + 'attribution', + 'breakdown', + 'funnel', + 'goal', + 'journey', + 'retention', + 'revenue', + 'utm', +]); + +export const goalReportSchema = z.object({ + type: z.literal('goal'), + parameters: z + .object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + type: z.string(), + value: z.string(), + operator: z.enum(['count', 'sum', 'average']).optional(), + property: z.string().optional(), + }) + .refine(data => { + if (data.type === 'event' && data.property) { + return data.operator && data.property; + } + return true; + }), +}); + +export const funnelReportSchema = z.object({ + type: z.literal('funnel'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + window: z.coerce.number().positive(), + steps: z + .array( + z.object({ + type: z.enum(['path', 'event']), + value: z.string(), + }), + ) + .min(2) + .max(8), + }), +}); + +export const journeyReportSchema = z.object({ + type: z.literal('journey'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + steps: z.coerce.number().min(2).max(7), + startStep: z.string().optional(), + endStep: z.string().optional(), + }), +}); + +export const retentionReportSchema = z.object({ + type: z.literal('retention'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + timezone: z.string().optional(), + }), +}); + +export const utmReportSchema = z.object({ + type: z.literal('utm'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + }), +}); + +export const revenueReportSchema = z.object({ + type: z.literal('revenue'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + timezone: z.string().optional(), + currency: z.string(), + }), +}); + +export const attributionReportSchema = z.object({ + type: z.literal('attribution'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + model: z.enum(['first-click', 'last-click']), + type: z.enum(['path', 'event']), + step: z.string(), + currency: z.string().optional(), + }), +}); + +export const breakdownReportSchema = z.object({ + type: z.literal('breakdown'), + parameters: z.object({ + startDate: z.coerce.date(), + endDate: z.coerce.date(), + fields: z.array(fieldsParam), + }), +}); + +export const reportBaseSchema = z.object({ + websiteId: z.uuid(), + type: reportTypeParam, + name: z.string().max(200), + description: z.string().max(500).optional(), + parameters: anyObjectParam, +}); + +export const reportTypeSchema = z.discriminatedUnion('type', [ + goalReportSchema, + funnelReportSchema, + journeyReportSchema, + retentionReportSchema, + utmReportSchema, + revenueReportSchema, + attributionReportSchema, + breakdownReportSchema, +]); + +export const reportSchema = reportBaseSchema; + +export const reportResultSchema = z.intersection( + z.object({ + websiteId: z.uuid(), + filters: z.object({ ...filterParams }), + }), + reportTypeSchema, +); + +export const segmentTypeParam = z.enum(['segment', 'cohort']); diff --git a/src/lib/sql.ts b/src/lib/sql.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/lib/sql.ts diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..19681a2 --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,25 @@ +export function setItem(key: string, data: any, session?: boolean) { + if (typeof window !== 'undefined' && data) { + return (session ? sessionStorage : localStorage).setItem(key, JSON.stringify(data)); + } +} + +export function getItem(key: string, session?: boolean): any { + if (typeof window !== 'undefined') { + const value = (session ? sessionStorage : localStorage).getItem(key); + + if (value !== 'undefined' && value !== null) { + try { + return JSON.parse(value); + } catch { + return null; + } + } + } +} + +export function removeItem(key: string, session?: boolean) { + if (typeof window !== 'undefined') { + return (session ? sessionStorage : localStorage).removeItem(key); + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..9c06197 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,143 @@ +import type { UseQueryOptions } from '@tanstack/react-query'; +import type { DATA_TYPE, OPERATORS, ROLES } from './constants'; +import type { TIME_UNIT } from './date'; + +export type ObjectValues<T> = T[keyof T]; + +export type ReactQueryOptions<T = any> = Omit<UseQueryOptions<T, Error, T>, 'queryKey' | 'queryFn'>; + +export type TimeUnit = ObjectValues<typeof TIME_UNIT>; +export type Role = ObjectValues<typeof ROLES>; +export type DynamicDataType = ObjectValues<typeof DATA_TYPE>; +export type Operator = (typeof OPERATORS)[keyof typeof OPERATORS]; + +export interface Auth { + user?: { + id: string; + username: string; + role: string; + isAdmin: boolean; + }; + shareToken?: { + websiteId: string; + }; +} + +export interface Filter { + name: string; + operator: Operator; + value: string; + type?: string; + column?: string; + prefix?: string; +} + +export interface DateRange { + startDate: Date; + endDate: Date; + value?: string; + unit?: TimeUnit; + num?: number; + offset?: number; +} + +export interface DynamicData { + [key: string]: number | string | number[] | string[]; +} + +export interface QueryOptions { + joinSession?: boolean; + columns?: Record<string, string>; + limit?: number; + prefix?: string; + isCohort?: boolean; +} + +export interface QueryFilters + extends DateParams, + FilterParams, + SortParams, + PageParams, + SegmentParams { + cohortFilters?: QueryFilters; +} + +export interface DateParams { + startDate?: Date; + endDate?: Date; + unit?: string; + timezone?: string; + compareDate?: Date; +} + +export interface FilterParams { + path?: string; + referrer?: string; + title?: string; + query?: string; + host?: string; + os?: string; + browser?: string; + device?: string; + country?: string; + region?: string; + city?: string; + language?: string; + event?: string; + search?: string; + tag?: string; + eventType?: number; + segment?: string; + cohort?: string; + compare?: string; +} + +export interface SortParams { + orderBy?: string; + sortDescending?: boolean; +} + +export interface PageParams { + page?: number; + pageSize?: number; +} + +export interface SegmentParams { + segment?: string; + cohort?: string; +} + +export interface PageResult<T> { + data: T; + count: number; + page: number; + pageSize: number; + orderBy?: string; + sortDescending?: boolean; + search?: string; +} + +export interface RealtimeData { + countries: Record<string, number>; + events: any[]; + pageviews: any[]; + referrers: Record<string, number>; + timestamp: number; + series: { + views: any[]; + visitors: any[]; + }; + totals: { + views: number; + visitors: number; + events: number; + countries: number; + }; + urls: Record<string, number>; + visitors: any[]; +} + +export interface ApiError extends Error { + code?: string; + message: string; +} diff --git a/src/lib/url.ts b/src/lib/url.ts new file mode 100644 index 0000000..f6772fe --- /dev/null +++ b/src/lib/url.ts @@ -0,0 +1,49 @@ +export function getQueryString(params: object = {}): string { + const searchParams = new URLSearchParams(); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value); + } + }); + + return searchParams.toString(); +} + +export function buildPath(path: string, params: object = {}): string { + const queryString = getQueryString(params); + return queryString ? `${path}?${queryString}` : path; +} + +export function safeDecodeURI(s: string | undefined | null): string | undefined | null { + if (s === undefined || s === null) { + return s; + } + + try { + return decodeURI(s); + } catch { + return s; + } +} + +export function safeDecodeURIComponent(s: string | undefined | null): string | undefined | null { + if (s === undefined || s === null) { + return s; + } + + try { + return decodeURIComponent(s); + } catch { + return s; + } +} + +export function isValidUrl(url: string) { + try { + new URL(url); + return true; + } catch { + return false; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..2b0d9ff --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,46 @@ +export function hook( + _this: { [x: string]: any }, + method: string | number, + callback: (arg0: any) => void, +) { + const orig = _this[method]; + + return (...args: any) => { + callback.apply(_this, args); + + return orig.apply(_this, args); + }; +} + +export function sleep(ms: number | undefined) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export function shuffleArray(a) { + const arr = a.slice(); + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = arr[i]; + arr[i] = arr[j]; + arr[j] = temp; + } + return arr; +} + +export function chunkArray(arr: any[], size: number) { + const chunks: any[] = []; + + let index = 0; + while (index < arr.length) { + chunks.push(arr.slice(index, size + index)); + index += size; + } + + return chunks; +} + +export function ensureArray(arr?: any) { + if (arr === undefined || arr === null) return []; + if (Array.isArray(arr)) return arr; + return [arr]; +} diff --git a/src/permissions/index.ts b/src/permissions/index.ts new file mode 100644 index 0000000..a70808e --- /dev/null +++ b/src/permissions/index.ts @@ -0,0 +1,6 @@ +export * from './link'; +export * from './pixel'; +export * from './report'; +export * from './team'; +export * from './user'; +export * from './website'; diff --git a/src/permissions/link.ts b/src/permissions/link.ts new file mode 100644 index 0000000..c027a0b --- /dev/null +++ b/src/permissions/link.ts @@ -0,0 +1,64 @@ +import { hasPermission } from '@/lib/auth'; +import { PERMISSIONS } from '@/lib/constants'; +import type { Auth } from '@/lib/types'; +import { getLink, getTeamUser } from '@/queries/prisma'; + +export async function canViewLink({ user }: Auth, linkId: string) { + if (user?.isAdmin) { + return true; + } + + const link = await getLink(linkId); + + if (link.userId) { + return user.id === link.userId; + } + + if (link.teamId) { + const teamUser = await getTeamUser(link.teamId, user.id); + + return !!teamUser; + } + + return false; +} + +export async function canUpdateLink({ user }: Auth, linkId: string) { + if (user.isAdmin) { + return true; + } + + const link = await getLink(linkId); + + if (link.userId) { + return user.id === link.userId; + } + + if (link.teamId) { + const teamUser = await getTeamUser(link.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate); + } + + return false; +} + +export async function canDeleteLink({ user }: Auth, linkId: string) { + if (user.isAdmin) { + return true; + } + + const link = await getLink(linkId); + + if (link.userId) { + return user.id === link.userId; + } + + if (link.teamId) { + const teamUser = await getTeamUser(link.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete); + } + + return false; +} diff --git a/src/permissions/pixel.ts b/src/permissions/pixel.ts new file mode 100644 index 0000000..2131874 --- /dev/null +++ b/src/permissions/pixel.ts @@ -0,0 +1,64 @@ +import { hasPermission } from '@/lib/auth'; +import { PERMISSIONS } from '@/lib/constants'; +import type { Auth } from '@/lib/types'; +import { getPixel, getTeamUser } from '@/queries/prisma'; + +export async function canViewPixel({ user }: Auth, pixelId: string) { + if (user?.isAdmin) { + return true; + } + + const pixel = await getPixel(pixelId); + + if (pixel.userId) { + return user.id === pixel.userId; + } + + if (pixel.teamId) { + const teamUser = await getTeamUser(pixel.teamId, user.id); + + return !!teamUser; + } + + return false; +} + +export async function canUpdatePixel({ user }: Auth, pixelId: string) { + if (user.isAdmin) { + return true; + } + + const pixel = await getPixel(pixelId); + + if (pixel.userId) { + return user.id === pixel.userId; + } + + if (pixel.teamId) { + const teamUser = await getTeamUser(pixel.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate); + } + + return false; +} + +export async function canDeletePixel({ user }: Auth, pixelId: string) { + if (user.isAdmin) { + return true; + } + + const pixel = await getPixel(pixelId); + + if (pixel.userId) { + return user.id === pixel.userId; + } + + if (pixel.teamId) { + const teamUser = await getTeamUser(pixel.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete); + } + + return false; +} diff --git a/src/permissions/report.ts b/src/permissions/report.ts new file mode 100644 index 0000000..01b5476 --- /dev/null +++ b/src/permissions/report.ts @@ -0,0 +1,27 @@ +import type { Report } from '@/generated/prisma/client'; +import type { Auth } from '@/lib/types'; +import { canViewWebsite } from './website'; + +export async function canViewReport(auth: Auth, report: Report) { + if (auth.user.isAdmin) { + return true; + } + + if (auth.user.id === report.userId) { + return true; + } + + return !!(await canViewWebsite(auth, report.websiteId)); +} + +export async function canUpdateReport({ user }: Auth, report: Report) { + if (user.isAdmin) { + return true; + } + + return user.id === report.userId; +} + +export async function canDeleteReport(auth: Auth, report: Report) { + return canUpdateReport(auth, report); +} diff --git a/src/permissions/team.ts b/src/permissions/team.ts new file mode 100644 index 0000000..0f07c1a --- /dev/null +++ b/src/permissions/team.ts @@ -0,0 +1,68 @@ +import { hasPermission } from '@/lib/auth'; +import { PERMISSIONS } from '@/lib/constants'; +import type { Auth } from '@/lib/types'; +import { getTeamUser } from '@/queries/prisma'; + +export async function canViewTeam({ user }: Auth, teamId: string) { + if (user.isAdmin) { + return true; + } + + return getTeamUser(teamId, user.id); +} + +export async function canCreateTeam({ user }: Auth) { + if (user.isAdmin) { + return true; + } + + return !!user; +} + +export async function canUpdateTeam({ user }: Auth, teamId: string) { + if (user.isAdmin) { + return true; + } + + const teamUser = await getTeamUser(teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamUpdate); +} + +export async function canDeleteTeam({ user }: Auth, teamId: string) { + if (user.isAdmin) { + return true; + } + + const teamUser = await getTeamUser(teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamDelete); +} + +export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUserId: string) { + if (user.isAdmin) { + return true; + } + + if (removeUserId === user.id) { + return true; + } + + const teamUser = await getTeamUser(teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamUpdate); +} + +export async function canCreateTeamWebsite({ user }: Auth, teamId: string) { + if (user.isAdmin) { + return true; + } + + const teamUser = await getTeamUser(teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteCreate); +} + +export async function canViewAllTeams({ user }: Auth) { + return user.isAdmin; +} diff --git a/src/permissions/user.ts b/src/permissions/user.ts new file mode 100644 index 0000000..2ed8f27 --- /dev/null +++ b/src/permissions/user.ts @@ -0,0 +1,29 @@ +import type { Auth } from '@/lib/types'; + +export async function canCreateUser({ user }: Auth) { + return user.isAdmin; +} + +export async function canViewUser({ user }: Auth, viewedUserId: string) { + if (user.isAdmin) { + return true; + } + + return user.id === viewedUserId; +} + +export async function canViewUsers({ user }: Auth) { + return user.isAdmin; +} + +export async function canUpdateUser({ user }: Auth, viewedUserId: string) { + if (user.isAdmin) { + return true; + } + + return user.id === viewedUserId; +} + +export async function canDeleteUser({ user }: Auth) { + return user.isAdmin; +} diff --git a/src/permissions/website.ts b/src/permissions/website.ts new file mode 100644 index 0000000..97952ee --- /dev/null +++ b/src/permissions/website.ts @@ -0,0 +1,128 @@ +import { hasPermission } from '@/lib/auth'; +import { PERMISSIONS } from '@/lib/constants'; +import type { Auth } from '@/lib/types'; +import { getLink, getPixel, getTeamUser, getWebsite } from '@/queries/prisma'; + +export async function canViewWebsite({ user, shareToken }: Auth, websiteId: string) { + if (user?.isAdmin) { + return true; + } + + if (shareToken?.websiteId === websiteId) { + return true; + } + + const website = await getWebsite(websiteId); + const link = await getLink(websiteId); + const pixel = await getPixel(websiteId); + + const entity = website || link || pixel; + + if (!entity) { + return false; + } + + if (entity.userId) { + return user.id === entity.userId; + } + + if (entity.teamId) { + const teamUser = await getTeamUser(entity.teamId, user.id); + + return !!teamUser; + } + + return false; +} + +export async function canViewAllWebsites({ user }: Auth) { + return user.isAdmin; +} + +export async function canCreateWebsite({ user }: Auth) { + if (user.isAdmin) { + return true; + } + + return hasPermission(user.role, PERMISSIONS.websiteCreate); +} + +export async function canUpdateWebsite({ user }: Auth, websiteId: string) { + if (user.isAdmin) { + return true; + } + + const website = await getWebsite(websiteId); + + if (!website) { + return false; + } + + if (website.userId) { + return user.id === website.userId; + } + + if (website.teamId) { + const teamUser = await getTeamUser(website.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate); + } + + return false; +} + +export async function canDeleteWebsite({ user }: Auth, websiteId: string) { + if (user.isAdmin) { + return true; + } + + const website = await getWebsite(websiteId); + + if (!website) { + return false; + } + + if (website.userId) { + return user.id === website.userId; + } + + if (website.teamId) { + const teamUser = await getTeamUser(website.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete); + } + + return false; +} + +export async function canTransferWebsiteToUser({ user }: Auth, websiteId: string, userId: string) { + const website = await getWebsite(websiteId); + + if (!website) { + return false; + } + + if (website.teamId && user.id === userId) { + const teamUser = await getTeamUser(website.teamId, userId); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteTransferToUser); + } + + return false; +} + +export async function canTransferWebsiteToTeam({ user }: Auth, websiteId: string, teamId: string) { + const website = await getWebsite(websiteId); + + if (!website) { + return false; + } + + if (website.userId && website.userId === user.id) { + const teamUser = await getTeamUser(teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteTransferToTeam); + } + + return false; +} diff --git a/src/queries/prisma/index.ts b/src/queries/prisma/index.ts new file mode 100644 index 0000000..b9730f5 --- /dev/null +++ b/src/queries/prisma/index.ts @@ -0,0 +1,8 @@ +export * from './link'; +export * from './pixel'; +export * from './report'; +export * from './segment'; +export * from './team'; +export * from './teamUser'; +export * from './user'; +export * from './website'; diff --git a/src/queries/prisma/link.ts b/src/queries/prisma/link.ts new file mode 100644 index 0000000..9b971de --- /dev/null +++ b/src/queries/prisma/link.ts @@ -0,0 +1,66 @@ +import type { Prisma } from '@/generated/prisma/client'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export async function findLink(criteria: Prisma.LinkFindUniqueArgs) { + return prisma.client.link.findUnique(criteria); +} + +export async function getLink(linkId: string) { + return findLink({ + where: { + id: linkId, + }, + }); +} + +export async function getLinks(criteria: Prisma.LinkFindManyArgs, filters: QueryFilters = {}) { + const { search } = filters; + const { getSearchParameters, pagedQuery } = prisma; + + const where: Prisma.LinkWhereInput = { + ...criteria.where, + ...getSearchParameters(search, [ + { name: 'contains' }, + { url: 'contains' }, + { slug: 'contains' }, + ]), + }; + + return pagedQuery('link', { ...criteria, where }, filters); +} + +export async function getUserLinks(userId: string, filters?: QueryFilters) { + return getLinks( + { + where: { + userId, + deletedAt: null, + }, + }, + filters, + ); +} + +export async function getTeamLinks(teamId: string, filters?: QueryFilters) { + return getLinks( + { + where: { + teamId, + }, + }, + filters, + ); +} + +export async function createLink(data: Prisma.LinkUncheckedCreateInput) { + return prisma.client.link.create({ data }); +} + +export async function updateLink(linkId: string, data: any) { + return prisma.client.link.update({ where: { id: linkId }, data }); +} + +export async function deleteLink(linkId: string) { + return prisma.client.link.delete({ where: { id: linkId } }); +} diff --git a/src/queries/prisma/pixel.ts b/src/queries/prisma/pixel.ts new file mode 100644 index 0000000..4c9e132 --- /dev/null +++ b/src/queries/prisma/pixel.ts @@ -0,0 +1,60 @@ +import type { Prisma } from '@/generated/prisma/client'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export async function findPixel(criteria: Prisma.PixelFindUniqueArgs) { + return prisma.client.pixel.findUnique(criteria); +} + +export async function getPixel(pixelId: string) { + return findPixel({ + where: { + id: pixelId, + }, + }); +} + +export async function getPixels(criteria: Prisma.PixelFindManyArgs, filters: QueryFilters = {}) { + const { search } = filters; + + const where: Prisma.PixelWhereInput = { + ...criteria.where, + ...prisma.getSearchParameters(search, [{ name: 'contains' }, { slug: 'contains' }]), + }; + + return prisma.pagedQuery('pixel', { ...criteria, where }, filters); +} + +export async function getUserPixels(userId: string, filters?: QueryFilters) { + return getPixels( + { + where: { + userId, + }, + }, + filters, + ); +} + +export async function getTeamPixels(teamId: string, filters?: QueryFilters) { + return getPixels( + { + where: { + teamId, + }, + }, + filters, + ); +} + +export async function createPixel(data: Prisma.PixelUncheckedCreateInput) { + return prisma.client.pixel.create({ data }); +} + +export async function updatePixel(pixelId: string, data: any) { + return prisma.client.pixel.update({ where: { id: pixelId }, data }); +} + +export async function deletePixel(pixelId: string) { + return prisma.client.pixel.delete({ where: { id: pixelId } }); +} diff --git a/src/queries/prisma/report.ts b/src/queries/prisma/report.ts new file mode 100644 index 0000000..4a5b755 --- /dev/null +++ b/src/queries/prisma/report.ts @@ -0,0 +1,89 @@ +import { Prisma } from '@/generated/prisma/client'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +import ReportFindManyArgs = Prisma.ReportFindManyArgs; + +async function findReport(criteria: Prisma.ReportFindUniqueArgs) { + return prisma.client.report.findUnique(criteria); +} + +export async function getReport(reportId: string) { + return findReport({ + where: { + id: reportId, + }, + }); +} + +export async function getReports(criteria: ReportFindManyArgs, filters: QueryFilters = {}) { + const { search } = filters; + + const where: Prisma.ReportWhereInput = { + ...criteria.where, + ...prisma.getSearchParameters(search, [ + { name: 'contains' }, + { description: 'contains' }, + { type: 'contains' }, + { + user: { + username: 'contains', + }, + }, + { + website: { + name: 'contains', + }, + }, + { + website: { + domain: 'contains', + }, + }, + ]), + }; + + return prisma.pagedQuery('report', { ...criteria, where }, filters); +} + +export async function getUserReports(userId: string, filters?: QueryFilters) { + return getReports( + { + where: { + userId, + }, + include: { + website: { + select: { + domain: true, + userId: true, + }, + }, + }, + }, + filters, + ); +} + +export async function getWebsiteReports(websiteId: string, filters: QueryFilters = {}) { + return getReports( + { + where: { + websiteId, + }, + }, + filters, + ); +} + +export async function createReport(data: Prisma.ReportUncheckedCreateInput) { + return prisma.client.report.create({ data }); +} + +export async function updateReport(reportId: string, data: any) { + return prisma.client.report.update({ where: { id: reportId }, data }); +} + +export async function deleteReport(reportId: string) { + return prisma.client.report.delete({ where: { id: reportId } }); +} diff --git a/src/queries/prisma/segment.ts b/src/queries/prisma/segment.ts new file mode 100644 index 0000000..3a17d27 --- /dev/null +++ b/src/queries/prisma/segment.ts @@ -0,0 +1,61 @@ +import type { Prisma } from '@/generated/prisma/client'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +async function findSegment(criteria: Prisma.SegmentFindUniqueArgs) { + return prisma.client.segment.findUnique(criteria); +} + +export async function getSegment(segmentId: string) { + return findSegment({ + where: { + id: segmentId, + }, + }); +} + +export async function getSegments(criteria: Prisma.SegmentFindManyArgs, filters: QueryFilters) { + const { search } = filters; + const { getSearchParameters, pagedQuery } = prisma; + + const where: Prisma.SegmentWhereInput = { + ...criteria.where, + ...getSearchParameters(search, [ + { + name: 'contains', + }, + ]), + }; + + return pagedQuery('segment', { ...criteria, where }, filters); +} + +export async function getWebsiteSegment(websiteId: string, segmentId: string) { + return prisma.client.segment.findFirst({ + where: { id: segmentId, websiteId }, + }); +} + +export async function getWebsiteSegments(websiteId: string, type: string, filters?: QueryFilters) { + return getSegments( + { + where: { + websiteId, + type, + }, + }, + filters, + ); +} + +export async function createSegment(data: Prisma.SegmentUncheckedCreateInput) { + return prisma.client.segment.create({ data }); +} + +export async function updateSegment(SegmentId: string, data: Prisma.SegmentUpdateInput) { + return prisma.client.segment.update({ where: { id: SegmentId }, data }); +} + +export async function deleteSegment(SegmentId: string) { + return prisma.client.segment.delete({ where: { id: SegmentId } }); +} diff --git a/src/queries/prisma/team.ts b/src/queries/prisma/team.ts new file mode 100644 index 0000000..5987c1d --- /dev/null +++ b/src/queries/prisma/team.ts @@ -0,0 +1,165 @@ +import { Prisma, type Team } from '@/generated/prisma/client'; +import { ROLES } from '@/lib/constants'; +import { uuid } from '@/lib/crypto'; +import prisma from '@/lib/prisma'; +import type { PageResult, QueryFilters } from '@/lib/types'; + +import TeamFindManyArgs = Prisma.TeamFindManyArgs; + +export async function findTeam(criteria: Prisma.TeamFindUniqueArgs): Promise<Team> { + return prisma.client.team.findUnique(criteria); +} + +export async function getTeam( + teamId: string, + options: { includeMembers?: boolean } = {}, +): Promise<Team> { + const { includeMembers } = options; + + return findTeam({ + where: { + id: teamId, + }, + ...(includeMembers && { include: { members: true } }), + }); +} + +export async function getTeams( + criteria: TeamFindManyArgs, + filters: QueryFilters, +): Promise<PageResult<Team[]>> { + const { getSearchParameters } = prisma; + const { search } = filters; + + const where: Prisma.TeamWhereInput = { + ...criteria.where, + ...getSearchParameters(search, [{ name: 'contains' }]), + }; + + return prisma.pagedQuery<TeamFindManyArgs>( + 'team', + { + ...criteria, + where, + }, + filters, + ); +} + +export async function getUserTeams(userId: string, filters: QueryFilters = {}) { + return getTeams( + { + where: { + deletedAt: null, + members: { + some: { userId }, + }, + }, + include: { + members: { + include: { + user: { + select: { + id: true, + username: true, + }, + }, + }, + }, + _count: { + select: { + websites: { + where: { deletedAt: null }, + }, + members: { + where: { + user: { deletedAt: null }, + }, + }, + }, + }, + }, + }, + filters, + ); +} + +export async function getAllUserTeams(userId: string) { + return prisma.client.team.findMany({ + where: { + deletedAt: null, + members: { + some: { userId }, + }, + }, + select: { + id: true, + name: true, + logoUrl: true, + }, + }); +} + +export async function createTeam(data: Prisma.TeamCreateInput, userId: string): Promise<any> { + const { id } = data; + const { client, transaction } = prisma; + + return transaction([ + client.team.create({ + data, + }), + client.teamUser.create({ + data: { + id: uuid(), + teamId: id, + userId, + role: ROLES.teamOwner, + }, + }), + ]); +} + +export async function updateTeam(teamId: string, data: Prisma.TeamUpdateInput): Promise<Team> { + const { client } = prisma; + + return client.team.update({ + where: { + id: teamId, + }, + data: { + ...data, + updatedAt: new Date(), + }, + }); +} + +export async function deleteTeam(teamId: string) { + const { client, transaction } = prisma; + const cloudMode = !!process.env.CLOUD_MODE; + + if (cloudMode) { + return transaction([ + client.team.update({ + data: { + deletedAt: new Date(), + }, + where: { + id: teamId, + }, + }), + ]); + } + + return transaction([ + client.teamUser.deleteMany({ + where: { + teamId, + }, + }), + client.team.delete({ + where: { + id: teamId, + }, + }), + ]); +} diff --git a/src/queries/prisma/teamUser.ts b/src/queries/prisma/teamUser.ts new file mode 100644 index 0000000..2210dee --- /dev/null +++ b/src/queries/prisma/teamUser.ts @@ -0,0 +1,66 @@ +import { Prisma } from '@/generated/prisma/client'; +import { uuid } from '@/lib/crypto'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +import TeamUserFindManyArgs = Prisma.TeamUserFindManyArgs; + +export async function findTeamUser(criteria: Prisma.TeamUserFindUniqueArgs) { + return prisma.client.teamUser.findUnique(criteria); +} + +export async function getTeamUser(teamId: string, userId: string) { + return prisma.client.teamUser.findFirst({ + where: { + teamId, + userId, + }, + }); +} + +export async function getTeamUsers(criteria: TeamUserFindManyArgs, filters?: QueryFilters) { + const { search } = filters; + + const where: Prisma.TeamUserWhereInput = { + ...criteria.where, + ...prisma.getSearchParameters(search, [{ user: { username: 'contains' } }]), + }; + + return prisma.pagedQuery( + 'teamUser', + { + ...criteria, + where, + }, + filters, + ); +} + +export async function createTeamUser(userId: string, teamId: string, role: string) { + return prisma.client.teamUser.create({ + data: { + id: uuid(), + userId, + teamId, + role, + }, + }); +} + +export async function updateTeamUser(teamUserId: string, data: Prisma.TeamUserUpdateInput) { + return prisma.client.teamUser.update({ + where: { + id: teamUserId, + }, + data, + }); +} + +export async function deleteTeamUser(teamId: string, userId: string) { + return prisma.client.teamUser.deleteMany({ + where: { + teamId, + userId, + }, + }); +} diff --git a/src/queries/prisma/user.ts b/src/queries/prisma/user.ts new file mode 100644 index 0000000..14376fc --- /dev/null +++ b/src/queries/prisma/user.ts @@ -0,0 +1,206 @@ +import { Prisma } from '@/generated/prisma/client'; +import { ROLES } from '@/lib/constants'; +import { getRandomChars } from '@/lib/generate'; +import prisma from '@/lib/prisma'; +import type { QueryFilters, Role } from '@/lib/types'; + +import UserFindManyArgs = Prisma.UserFindManyArgs; + +export interface GetUserOptions { + includePassword?: boolean; + showDeleted?: boolean; +} + +async function findUser(criteria: Prisma.UserFindUniqueArgs, options: GetUserOptions = {}) { + const { includePassword = false, showDeleted = false } = options; + + return prisma.client.user.findUnique({ + ...criteria, + where: { + ...criteria.where, + ...(showDeleted && { deletedAt: null }), + }, + select: { + id: true, + username: true, + password: includePassword, + role: true, + createdAt: true, + }, + }); +} + +export async function getUser(userId: string, options: GetUserOptions = {}) { + return findUser( + { + where: { + id: userId, + }, + }, + options, + ); +} + +export async function getUserByUsername(username: string, options: GetUserOptions = {}) { + return findUser({ where: { username } }, options); +} + +export async function getUsers(criteria: UserFindManyArgs, filters: QueryFilters = {}) { + const { search } = filters; + + const where: Prisma.UserWhereInput = { + ...criteria.where, + ...prisma.getSearchParameters(search, [{ username: 'contains' }]), + deletedAt: null, + }; + + return prisma.pagedQuery( + 'user', + { + ...criteria, + where, + }, + { + orderBy: 'createdAt', + sortDescending: true, + ...filters, + }, + ); +} + +export async function createUser(data: { + id: string; + username: string; + password: string; + role: Role; +}) { + return prisma.client.user.create({ + data, + select: { + id: true, + username: true, + role: true, + }, + }); +} + +export async function updateUser(userId: string, data: Prisma.UserUpdateInput) { + return prisma.client.user.update({ + where: { + id: userId, + }, + data, + select: { + id: true, + username: true, + role: true, + createdAt: true, + }, + }); +} + +export async function deleteUser(userId: string) { + const { client, transaction } = prisma; + const cloudMode = !!process.env.CLOUD_MODE; + + const websites = await client.website.findMany({ + where: { userId }, + }); + + let websiteIds = []; + + if (websites.length > 0) { + websiteIds = websites.map(a => a.id); + } + + const teams = await client.team.findMany({ + where: { + members: { + some: { + userId, + role: ROLES.teamOwner, + }, + }, + }, + }); + + const teamIds = teams.map(a => a.id); + + if (cloudMode) { + return transaction([ + client.website.updateMany({ + data: { + deletedAt: new Date(), + }, + where: { id: { in: websiteIds } }, + }), + client.user.update({ + data: { + username: getRandomChars(32), + deletedAt: new Date(), + }, + where: { + id: userId, + }, + }), + ]); + } + + return transaction([ + client.eventData.deleteMany({ + where: { websiteId: { in: websiteIds } }, + }), + client.sessionData.deleteMany({ + where: { websiteId: { in: websiteIds } }, + }), + client.websiteEvent.deleteMany({ + where: { websiteId: { in: websiteIds } }, + }), + client.session.deleteMany({ + where: { websiteId: { in: websiteIds } }, + }), + client.teamUser.deleteMany({ + where: { + OR: [ + { + teamId: { + in: teamIds, + }, + }, + { + userId, + }, + ], + }, + }), + client.team.deleteMany({ + where: { + id: { + in: teamIds, + }, + }, + }), + client.report.deleteMany({ + where: { + OR: [ + { + websiteId: { + in: websiteIds, + }, + }, + { + userId, + }, + ], + }, + }), + client.website.deleteMany({ + where: { id: { in: websiteIds } }, + }), + client.user.delete({ + where: { + id: userId, + }, + }), + ]); +} diff --git a/src/queries/prisma/website.ts b/src/queries/prisma/website.ts new file mode 100644 index 0000000..79cb724 --- /dev/null +++ b/src/queries/prisma/website.ts @@ -0,0 +1,234 @@ +import type { Prisma } from '@/generated/prisma/client'; +import { ROLES } from '@/lib/constants'; +import prisma from '@/lib/prisma'; +import redis from '@/lib/redis'; +import type { QueryFilters } from '@/lib/types'; + +export async function findWebsite(criteria: Prisma.WebsiteFindUniqueArgs) { + return prisma.client.website.findUnique(criteria); +} + +export async function getWebsite(websiteId: string) { + return findWebsite({ + where: { + id: websiteId, + }, + }); +} + +export async function getSharedWebsite(shareId: string) { + return findWebsite({ + where: { + shareId, + deletedAt: null, + }, + }); +} + +export async function getWebsites(criteria: Prisma.WebsiteFindManyArgs, filters: QueryFilters) { + const { search } = filters; + const { getSearchParameters, pagedQuery } = prisma; + + const where: Prisma.WebsiteWhereInput = { + ...criteria.where, + ...getSearchParameters(search, [ + { + name: 'contains', + }, + { domain: 'contains' }, + ]), + deletedAt: null, + }; + + return pagedQuery('website', { ...criteria, where }, filters); +} + +export async function getAllUserWebsitesIncludingTeamOwner(userId: string, filters?: QueryFilters) { + return getWebsites( + { + where: { + OR: [ + { userId }, + { + team: { + deletedAt: null, + members: { + some: { + role: ROLES.teamOwner, + userId, + }, + }, + }, + }, + ], + }, + }, + { + orderBy: 'name', + ...filters, + }, + ); +} + +export async function getUserWebsites(userId: string, filters?: QueryFilters) { + return getWebsites( + { + where: { + userId, + }, + include: { + user: { + select: { + username: true, + id: true, + }, + }, + }, + }, + { + orderBy: 'name', + ...filters, + }, + ); +} + +export async function getTeamWebsites(teamId: string, filters?: QueryFilters) { + return getWebsites( + { + where: { + teamId, + }, + include: { + createUser: { + select: { + id: true, + username: true, + }, + }, + }, + }, + filters, + ); +} + +export async function createWebsite( + data: Prisma.WebsiteCreateInput | Prisma.WebsiteUncheckedCreateInput, +) { + return prisma.client.website.create({ + data, + }); +} + +export async function updateWebsite( + websiteId: string, + data: Prisma.WebsiteUpdateInput | Prisma.WebsiteUncheckedUpdateInput, +) { + return prisma.client.website.update({ + where: { + id: websiteId, + }, + data, + }); +} + +export async function resetWebsite(websiteId: string) { + const { client, transaction } = prisma; + const cloudMode = !!process.env.CLOUD_MODE; + + return transaction( + [ + client.revenue.deleteMany({ + where: { websiteId }, + }), + client.eventData.deleteMany({ + where: { websiteId }, + }), + client.sessionData.deleteMany({ + where: { websiteId }, + }), + client.websiteEvent.deleteMany({ + where: { websiteId }, + }), + client.session.deleteMany({ + where: { websiteId }, + }), + client.website.update({ + where: { id: websiteId }, + data: { + resetAt: new Date(), + }, + }), + ], + { + timeout: 30000, + }, + ).then(async data => { + if (cloudMode) { + await redis.client.set( + `website:${websiteId}`, + data.find(website => website.id), + ); + } + + return data; + }); +} + +export async function deleteWebsite(websiteId: string) { + const { client, transaction } = prisma; + const cloudMode = !!process.env.CLOUD_MODE; + + return transaction( + [ + client.revenue.deleteMany({ + where: { websiteId }, + }), + client.eventData.deleteMany({ + where: { websiteId }, + }), + client.sessionData.deleteMany({ + where: { websiteId }, + }), + client.websiteEvent.deleteMany({ + where: { websiteId }, + }), + client.session.deleteMany({ + where: { websiteId }, + }), + client.report.deleteMany({ + where: { websiteId }, + }), + client.segment.deleteMany({ + where: { websiteId }, + }), + cloudMode + ? client.website.update({ + data: { + deletedAt: new Date(), + }, + where: { id: websiteId }, + }) + : client.website.delete({ + where: { id: websiteId }, + }), + ], + { + timeout: 30000, + }, + ).then(async data => { + if (cloudMode) { + await redis.client.del(`website:${websiteId}`); + } + + return data; + }); +} + +export async function getWebsiteCount(userId: string) { + return prisma.client.website.count({ + where: { + userId, + deletedAt: null, + }, + }); +} diff --git a/src/queries/sql/events/getEventData.ts b/src/queries/sql/events/getEventData.ts new file mode 100644 index 0000000..f12c95c --- /dev/null +++ b/src/queries/sql/events/getEventData.ts @@ -0,0 +1,63 @@ +import type { EventData } from '@/generated/prisma/client'; +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; + +const FUNCTION_NAME = 'getEventData'; + +export async function getEventData( + ...args: [websiteId: string, eventId: string] +): Promise<EventData[]> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, eventId: string) { + const { rawQuery } = prisma; + + return rawQuery( + ` + select event_data.website_id as "websiteId", + event_data.website_event_id as "eventId", + website_event.event_name as "eventName", + event_data.data_key as "dataKey", + event_data.string_value as "stringValue", + event_data.number_value as "numberValue", + event_data.date_value as "dateValue", + event_data.data_type as "dataType", + event_data.created_at as "createdAt" + from event_data + join website_event on website_event.event_id = event_data.website_event_id + and website_event.website_id = {{websiteId::uuid}} + where event_data.website_id = {{websiteId::uuid}} + and event_data.website_event_id = {{eventId::uuid}} + `, + { websiteId, eventId }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery(websiteId: string, eventId: string): Promise<EventData[]> { + const { rawQuery } = clickhouse; + + return rawQuery( + ` + select website_id as websiteId, + event_id as eventId, + event_name as eventName, + data_key as dataKey, + string_value as stringValue, + number_value as numberValue, + date_value as dateValue, + data_type as dataType, + created_at as createdAt + from event_data + where website_id = {websiteId:UUID} + and event_id = {eventId:UUID} + `, + { websiteId, eventId }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/getEventDataEvents.ts b/src/queries/sql/events/getEventDataEvents.ts new file mode 100644 index 0000000..6c8f12c --- /dev/null +++ b/src/queries/sql/events/getEventDataEvents.ts @@ -0,0 +1,139 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getEventDataEvents'; + +export interface WebsiteEventData { + eventName?: string; + propertyName: string; + dataType: number; + propertyValue?: string; + total: number; +} + +export async function getEventDataEvents( + ...args: [websiteId: string, filters: QueryFilters] +): Promise<WebsiteEventData[]> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { rawQuery, parseFilters } = prisma; + const { event } = filters; + const { queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + if (event) { + return rawQuery( + ` + select + website_event.event_name as "eventName", + event_data.data_key as "propertyName", + event_data.data_type as "dataType", + event_data.string_value as "propertyValue", + count(*) as "total" + from event_data + inner join website_event + on website_event.event_id = event_data.website_event_id + where event_data.website_id = {{websiteId::uuid}} + and event_data.created_at between {{startDate}} and {{endDate}} + and website_event.event_name = {{event}} + group by website_event.event_name, event_data.data_key, event_data.data_type, event_data.string_value + order by 1 asc, 2 asc, 3 asc, 5 desc + `, + queryParams, + FUNCTION_NAME, + ); + } + + return rawQuery( + ` + select + website_event.event_name as "eventName", + event_data.data_key as "propertyName", + event_data.data_type as "dataType", + count(*) as "total" + from event_data + inner join website_event + on website_event.event_id = event_data.website_event_id + where event_data.website_id = {{websiteId::uuid}} + and event_data.created_at between {{startDate}} and {{endDate}} + limit 500 + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<{ eventName: string; propertyName: string; dataType: number; total: number }[]> { + const { rawQuery, parseFilters } = clickhouse; + const { event } = filters; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + if (event) { + return rawQuery( + ` + select + event_name as eventName, + data_key as propertyName, + data_type as dataType, + string_value as propertyValue, + count(*) as total + from event_data + join website_event + on website_event.event_id = event_data.event_id + and website_event.website_id = event_data.website_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where event_data.website_id = {websiteId:UUID} + and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_data.event_name = {event:String} + ${filterQuery} + group by data_key, data_type, string_value, event_name + order by 1 asc, 2 asc, 3 asc, 5 desc + limit 500 + `, + queryParams, + FUNCTION_NAME, + ); + } + + return rawQuery( + ` + select + event_name as eventName, + data_key as propertyName, + data_type as dataType, + count(*) as total + from event_data + join website_event + on website_event.event_id = event_data.event_id + and website_event.website_id = event_data.website_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where event_data.website_id = {websiteId:UUID} + and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by data_key, data_type, event_name + order by 1 asc, 2 asc + limit 500 + `, + queryParams, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/getEventDataFields.ts b/src/queries/sql/events/getEventDataFields.ts new file mode 100644 index 0000000..9337769 --- /dev/null +++ b/src/queries/sql/events/getEventDataFields.ts @@ -0,0 +1,84 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getEventDataFields'; + +export async function getEventDataFields(...args: [websiteId: string, filters: QueryFilters]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { rawQuery, parseFilters, getDateSQL } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + data_key as "propertyName", + data_type as "dataType", + case + when data_type = 2 then replace(string_value, '.0000', '') + when data_type = 4 then ${getDateSQL('date_value', 'hour')} + else string_value + end as "value", + count(*) as "total" + from event_data + join website_event on website_event.event_id = event_data.website_event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${cohortQuery} + ${joinSessionQuery} + where event_data.website_id = {{websiteId::uuid}} + and event_data.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by data_key, data_type, value + order by 2 desc + limit 100 + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + + return rawQuery( + ` + select + data_key as propertyName, + data_type as dataType, + multiIf(data_type = 2, replaceAll(string_value, '.0000', ''), + data_type = 4, toString(date_trunc('hour', date_value)), + string_value) as "value", + count(*) as "total" + from event_data + join website_event + on website_event.event_id = event_data.event_id + and website_event.website_id = event_data.website_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where event_data.website_id = {websiteId:UUID} + and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by data_key, data_type, value + order by 2 desc + limit 100 + `, + queryParams, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/getEventDataProperties.ts b/src/queries/sql/events/getEventDataProperties.ts new file mode 100644 index 0000000..82c078f --- /dev/null +++ b/src/queries/sql/events/getEventDataProperties.ts @@ -0,0 +1,88 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getEventDataProperties'; + +export async function getEventDataProperties( + ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string }] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + filters: QueryFilters & { propertyName?: string }, +) { + const { rawQuery, parseFilters } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters( + { ...filters, websiteId }, + { + columns: { propertyName: 'data_key' }, + }, + ); + + return rawQuery( + ` + select + website_event.event_name as "eventName", + event_data.data_key as "propertyName", + count(*) as "total" + from event_data + join website_event on website_event.event_id = event_data.website_event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${cohortQuery} + ${joinSessionQuery} + where event_data.website_id = {{websiteId::uuid}} + and event_data.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by website_event.event_name, event_data.data_key + order by 3 desc + limit 500 + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters & { propertyName?: string }, +): Promise<{ eventName: string; propertyName: string; total: number }[]> { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters( + { ...filters, websiteId }, + { + columns: { propertyName: 'data_key' }, + }, + ); + + return rawQuery( + ` + select + event_name as eventName, + data_key as propertyName, + count(*) as total + from event_data + join website_event + on website_event.event_id = event_data.event_id + and website_event.website_id = event_data.website_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where event_data.website_id = {websiteId:UUID} + and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by event_name, data_key + order by 1, 3 desc + limit 500 + `, + queryParams, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/getEventDataStats.ts b/src/queries/sql/events/getEventDataStats.ts new file mode 100644 index 0000000..89e1358 --- /dev/null +++ b/src/queries/sql/events/getEventDataStats.ts @@ -0,0 +1,90 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getEventDataStats'; + +export async function getEventDataStats( + ...args: [websiteId: string, filters: QueryFilters] +): Promise<{ + events: number; + properties: number; + records: number; +}> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }).then(results => results?.[0]); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { rawQuery, parseFilters } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + count(distinct t.website_event_id) as "events", + count(distinct t.data_key) as "properties", + sum(t.total) as "records" + from ( + select + website_event_id, + data_key, + count(*) as "total" + from event_data + join website_event on website_event.event_id = event_data.website_event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${cohortQuery} + ${joinSessionQuery} + where event_data.website_id = {{websiteId::uuid}} + and event_data.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by website_event_id, data_key + ) as t + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<{ events: number; properties: number; records: number }[]> { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + + return rawQuery( + ` + select + count(distinct t.event_id) as "events", + count(distinct t.data_key) as "properties", + sum(t.total) as "records" + from ( + select + event_id, + data_key, + count(*) as "total" + from event_data + join website_event + on website_event.event_id = event_data.event_id + and website_event.website_id = event_data.website_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where event_data.website_id = {websiteId:UUID} + and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by event_id, data_key + ) as t + `, + queryParams, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/getEventDataUsage.ts b/src/queries/sql/events/getEventDataUsage.ts new file mode 100644 index 0000000..50613a7 --- /dev/null +++ b/src/queries/sql/events/getEventDataUsage.ts @@ -0,0 +1,38 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, notImplemented, PRISMA, runQuery } from '@/lib/db'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getEventDataUsage'; + +export function getEventDataUsage(...args: [websiteIds: string[], filters: QueryFilters]) { + return runQuery({ + [PRISMA]: notImplemented, + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +function clickhouseQuery( + websiteIds: string[], + filters: QueryFilters, +): Promise<{ websiteId: string; count: number }[]> { + const { rawQuery } = clickhouse; + const { startDate, endDate } = filters; + + return rawQuery( + ` + select + website_id as websiteId, + count(*) as count + from event_data + where created_at between {startDate:DateTime64} and {endDate:DateTime64} + and website_id in {websiteIds:Array(UUID)} + group by website_id + `, + { + websiteIds, + startDate, + endDate, + }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/getEventDataValues.ts b/src/queries/sql/events/getEventDataValues.ts new file mode 100644 index 0000000..0426e64 --- /dev/null +++ b/src/queries/sql/events/getEventDataValues.ts @@ -0,0 +1,93 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getEventDataValues'; + +interface WebsiteEventData { + value: string; + total: number; +} + +export async function getEventDataValues( + ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string }] +): Promise<WebsiteEventData[]> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + filters: QueryFilters & { propertyName?: string }, +) { + const { rawQuery, parseFilters, getDateSQL } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + case + when data_type = 2 then replace(string_value, '.0000', '') + when data_type = 4 then ${getDateSQL('date_value', 'hour')} + else string_value + end as "value", + count(*) as "total" + from event_data + join website_event on website_event.event_id = event_data.website_event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${cohortQuery} + ${joinSessionQuery} + where event_data.website_id = {{websiteId::uuid}} + and event_data.created_at between {{startDate}} and {{endDate}} + and event_data.data_key = {{propertyName}} + ${filterQuery} + group by value + order by 2 desc + limit 100 + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters & { propertyName?: string }, +): Promise<{ value: string; total: number }[]> { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + + return rawQuery( + ` + select + multiIf(data_type = 2, replaceAll(string_value, '.0000', ''), + data_type = 4, toString(date_trunc('hour', date_value)), + string_value) as "value", + count(*) as "total" + from event_data + join website_event + on website_event.event_id = event_data.event_id + and website_event.website_id = event_data.website_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where event_data.website_id = {websiteId:UUID} + and event_data.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_data.data_key = {propertyName:String} + and event_data.event_name = {event:String} + ${filterQuery} + group by value + order by 2 desc + limit 100 + `, + queryParams, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/getEventExpandedMetrics.ts b/src/queries/sql/events/getEventExpandedMetrics.ts new file mode 100644 index 0000000..f03a347 --- /dev/null +++ b/src/queries/sql/events/getEventExpandedMetrics.ts @@ -0,0 +1,132 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getEventExpandedMetrics'; + +export interface EventExpandedMetricParameters { + type: string; + limit?: string; + offset?: string; +} + +export interface EventExpandedMetricData { + name: string; + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; +} + +export async function getEventExpandedMetrics( + ...args: [websiteId: string, parameters: EventExpandedMetricParameters, filters: QueryFilters] +): Promise<EventExpandedMetricData[]> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: EventExpandedMetricParameters, + filters: QueryFilters, +) { + const { type, limit = 500, offset = 0 } = parameters; + const column = FILTER_COLUMNS[type] || type; + const { rawQuery, parseFilters, getTimestampDiffSQL } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters( + { + ...filters, + websiteId, + eventType: EVENT_TYPE.customEvent, + }, + { joinSession: SESSION_COLUMNS.includes(type) }, + ); + + return rawQuery( + ` + select + name, + sum(t.c) as "pageviews", + count(distinct t.session_id) as "visitors", + count(distinct t.visit_id) as "visits", + sum(case when t.c = 1 then 1 else 0 end) as "bounces", + sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" + from ( + select + ${column} name, + website_event.session_id, + website_event.visit_id, + count(*) as "c", + min(website_event.created_at) as "min_time", + max(website_event.created_at) as "max_time" + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by name, website_event.session_id, website_event.visit_id + ) as t + group by name + order by visitors desc, visits desc + limit ${limit} + offset ${offset} + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: EventExpandedMetricParameters, + filters: QueryFilters, +): Promise<EventExpandedMetricData[]> { + const { type, limit = 500, offset = 0 } = parameters; + const column = FILTER_COLUMNS[type] || type; + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + eventType: EVENT_TYPE.customEvent, + }); + + return rawQuery( + ` + select + name, + sum(t.c) as "pageviews", + uniq(t.session_id) as "visitors", + uniq(t.visit_id) as "visits", + sum(if(t.c = 1, 1, 0)) as "bounces", + sum(max_time-min_time) as "totaltime" + from ( + select + ${column} name, + session_id, + visit_id, + count(*) c, + min(created_at) min_time, + max(created_at) max_time + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and name != '' + ${filterQuery} + group by name, session_id, visit_id + ) as t + group by name + order by visitors desc, visits desc + limit ${limit} + offset ${offset} + `, + { ...queryParams, ...parameters }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/getEventMetrics.ts b/src/queries/sql/events/getEventMetrics.ts new file mode 100644 index 0000000..500c67e --- /dev/null +++ b/src/queries/sql/events/getEventMetrics.ts @@ -0,0 +1,97 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getEventMetrics'; + +export interface EventMetricParameters { + type: string; + limit?: string; + offset?: string; +} + +export interface EventMetricData { + x: string; + t: string; + y: number; +} + +export async function getEventMetrics( + ...args: [websiteId: string, parameters: EventMetricParameters, filters: QueryFilters] +): Promise<EventMetricData[]> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: EventMetricParameters, + filters: QueryFilters, +) { + const { type, limit = 500, offset = 0 } = parameters; + const column = FILTER_COLUMNS[type] || type; + const { rawQuery, parseFilters } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters( + { + ...filters, + websiteId, + eventType: EVENT_TYPE.customEvent, + }, + { joinSession: SESSION_COLUMNS.includes(type) }, + ); + + return rawQuery( + ` + select ${column} x, + count(*) as y + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by 1 + order by 2 desc + limit ${limit} + offset ${offset} + `, + { ...queryParams, ...parameters }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: EventMetricParameters, + filters: QueryFilters, +): Promise<EventMetricData[]> { + const { type, limit = 500, offset = 0 } = parameters; + const column = FILTER_COLUMNS[type] || type; + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + eventType: EVENT_TYPE.customEvent, + }); + + return rawQuery( + `select ${column} x, + count(*) as y + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by x + order by y desc + limit ${limit} + offset ${offset} + `, + { ...queryParams, ...parameters }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/getEventStats.ts b/src/queries/sql/events/getEventStats.ts new file mode 100644 index 0000000..81d12a0 --- /dev/null +++ b/src/queries/sql/events/getEventStats.ts @@ -0,0 +1,101 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getEventStats'; + +interface WebsiteEventMetric { + x: string; + t: string; + y: number; +} + +export async function getEventStats( + ...args: [websiteId: string, filters: QueryFilters] +): Promise<WebsiteEventMetric[]> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { timezone = 'utc', unit = 'day' } = filters; + const { rawQuery, getDateSQL, parseFilters } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + eventType: EVENT_TYPE.customEvent, + }); + + return rawQuery( + ` + select + event_name x, + ${getDateSQL('website_event.created_at', unit, timezone)} t, + count(*) y + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by 1, 2 + order by 2 + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<{ x: string; t: string; y: number }[]> { + const { timezone = 'UTC', unit = 'day' } = filters; + const { rawQuery, getDateSQL, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + eventType: EVENT_TYPE.customEvent, + }); + + let sql = ''; + + if (filterQuery || cohortQuery) { + sql = ` + select + event_name x, + ${getDateSQL('created_at', unit, timezone)} t, + count(*) y + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by x, t + order by t + `; + } else { + sql = ` + select + event_name x, + ${getDateSQL('created_at', unit, timezone)} t, + count(*) y + from ( + select arrayJoin(event_name) as event_name, + created_at + from website_event_stats_hourly website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type = {eventType:UInt32} + ) as g + group by x, t + order by t + `; + } + + return rawQuery(sql, queryParams, FUNCTION_NAME); +} diff --git a/src/queries/sql/events/getEventUsage.ts b/src/queries/sql/events/getEventUsage.ts new file mode 100644 index 0000000..40f5a96 --- /dev/null +++ b/src/queries/sql/events/getEventUsage.ts @@ -0,0 +1,38 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, notImplemented, PRISMA, runQuery } from '@/lib/db'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getEventUsage'; + +export function getEventUsage(...args: [websiteIds: string[], filters: QueryFilters]) { + return runQuery({ + [PRISMA]: notImplemented, + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +function clickhouseQuery( + websiteIds: string[], + filters: QueryFilters, +): Promise<{ websiteId: string; count: number }[]> { + const { rawQuery } = clickhouse; + const { startDate, endDate } = filters; + + return rawQuery( + ` + select + website_id as websiteId, + count(*) as count + from website_event + where website_id in {websiteIds:Array(UUID)} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + group by website_id + `, + { + websiteIds, + startDate, + endDate, + }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/getWebsiteEvents.ts b/src/queries/sql/events/getWebsiteEvents.ts new file mode 100644 index 0000000..f11d3ff --- /dev/null +++ b/src/queries/sql/events/getWebsiteEvents.ts @@ -0,0 +1,119 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getWebsiteEvents'; + +export function getWebsiteEvents(...args: [websiteId: string, filters: QueryFilters]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { pagedRawQuery, parseFilters } = prisma; + const { search } = filters; + const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + const searchQuery = search + ? `and ((event_name ilike {{search}} and event_type = 2) + or (url_path ilike {{search}} and event_type = 1))` + : ''; + + return pagedRawQuery( + ` + select + website_event.event_id as "id", + website_event.website_id as "websiteId", + website_event.session_id as "sessionId", + website_event.created_at as "createdAt", + website_event.hostname, + website_event.url_path as "urlPath", + website_event.url_query as "urlQuery", + website_event.referrer_path as "referrerPath", + website_event.referrer_query as "referrerQuery", + website_event.referrer_domain as "referrerDomain", + session.country as country, + city as city, + device as device, + os as os, + browser as browser, + page_title as "pageTitle", + website_event.event_type as "eventType", + website_event.event_name as "eventName", + event_id IN (select website_event_id + from event_data + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}}) AS "hasData" + from website_event + ${cohortQuery} + join session on session.session_id = website_event.session_id + and session.website_id = website_event.website_id + where website_event.website_id = {{websiteId::uuid}} + ${dateQuery} + ${filterQuery} + ${searchQuery} + order by website_event.created_at desc + `, + queryParams, + filters, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { + const { pagedRawQuery, parseFilters } = clickhouse; + const { search } = filters; + const { queryParams, dateQuery, cohortQuery, filterQuery } = parseFilters({ + ...filters, + websiteId, + }); + + const searchQuery = search + ? `and ((positionCaseInsensitive(event_name, {search:String}) > 0 and event_type = 2) + or (positionCaseInsensitive(url_path, {search:String}) > 0 and event_type = 1))` + : ''; + + return pagedRawQuery( + ` + select + event_id as id, + website_id as websiteId, + session_id as sessionId, + created_at as createdAt, + hostname, + url_path as urlPath, + url_query as urlQuery, + referrer_path as referrerPath, + referrer_query as referrerQuery, + referrer_domain as referrerDomain, + country as country, + city as city, + device as device, + os as os, + browser as browser, + page_title as pageTitle, + event_type as eventType, + event_name as eventName, + event_id IN (select event_id + from event_data + where website_id = {websiteId:UUID} + ${dateQuery}) as hasData + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + ${dateQuery} + ${filterQuery} + ${searchQuery} + order by created_at desc + `, + queryParams, + filters, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/events/saveEvent.ts b/src/queries/sql/events/saveEvent.ts new file mode 100644 index 0000000..7313fe4 --- /dev/null +++ b/src/queries/sql/events/saveEvent.ts @@ -0,0 +1,249 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_NAME_LENGTH, PAGE_TITLE_LENGTH, URL_LENGTH } from '@/lib/constants'; +import { uuid } from '@/lib/crypto'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import kafka from '@/lib/kafka'; +import prisma from '@/lib/prisma'; +import { saveEventData } from './saveEventData'; +import { saveRevenue } from './saveRevenue'; + +export interface SaveEventArgs { + websiteId: string; + sessionId: string; + visitId: string; + eventType: number; + createdAt?: Date; + + // Page + pageTitle?: string; + hostname?: string; + urlPath: string; + urlQuery?: string; + referrerPath?: string; + referrerQuery?: string; + referrerDomain?: string; + + // Session + distinctId?: string; + browser?: string; + os?: string; + device?: string; + screen?: string; + language?: string; + country?: string; + region?: string; + city?: string; + + // Events + eventName?: string; + eventData?: any; + tag?: string; + + // UTM + utmSource?: string; + utmMedium?: string; + utmCampaign?: string; + utmContent?: string; + utmTerm?: string; + + // Click IDs + gclid?: string; + fbclid?: string; + msclkid?: string; + ttclid?: string; + lifatid?: string; + twclid?: string; +} + +export async function saveEvent(args: SaveEventArgs) { + return runQuery({ + [PRISMA]: () => relationalQuery(args), + [CLICKHOUSE]: () => clickhouseQuery(args), + }); +} + +async function relationalQuery({ + websiteId, + sessionId, + visitId, + eventType, + createdAt, + pageTitle, + hostname, + urlPath, + urlQuery, + referrerPath, + referrerQuery, + referrerDomain, + eventName, + eventData, + tag, + utmSource, + utmMedium, + utmCampaign, + utmContent, + utmTerm, + gclid, + fbclid, + msclkid, + ttclid, + lifatid, + twclid, +}: SaveEventArgs) { + const websiteEventId = uuid(); + + await prisma.client.websiteEvent.create({ + data: { + id: websiteEventId, + websiteId, + sessionId, + visitId, + urlPath: urlPath?.substring(0, URL_LENGTH), + urlQuery: urlQuery?.substring(0, URL_LENGTH), + utmSource, + utmMedium, + utmCampaign, + utmContent, + utmTerm, + referrerPath: referrerPath?.substring(0, URL_LENGTH), + referrerQuery: referrerQuery?.substring(0, URL_LENGTH), + referrerDomain: referrerDomain?.substring(0, URL_LENGTH), + pageTitle: pageTitle?.substring(0, PAGE_TITLE_LENGTH), + gclid, + fbclid, + msclkid, + ttclid, + lifatid, + twclid, + eventType, + eventName: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null, + tag, + hostname, + createdAt, + }, + }); + + if (eventData) { + await saveEventData({ + websiteId, + sessionId, + eventId: websiteEventId, + urlPath: urlPath?.substring(0, URL_LENGTH), + eventName: eventName?.substring(0, EVENT_NAME_LENGTH), + eventData, + createdAt, + }); + + const { revenue, currency } = eventData; + + if (revenue > 0 && currency) { + await saveRevenue({ + websiteId, + sessionId, + eventId: websiteEventId, + eventName: eventName?.substring(0, EVENT_NAME_LENGTH), + currency, + revenue, + createdAt, + }); + } + } +} + +async function clickhouseQuery({ + websiteId, + sessionId, + visitId, + eventType, + createdAt, + pageTitle, + hostname, + urlPath, + urlQuery, + referrerPath, + referrerQuery, + referrerDomain, + distinctId, + browser, + os, + device, + screen, + language, + country, + region, + city, + eventName, + eventData, + tag, + utmSource, + utmMedium, + utmCampaign, + utmContent, + utmTerm, + gclid, + fbclid, + msclkid, + ttclid, + lifatid, + twclid, +}: SaveEventArgs) { + const { insert, getUTCString } = clickhouse; + const { sendMessage } = kafka; + const eventId = uuid(); + + const message = { + website_id: websiteId, + session_id: sessionId, + visit_id: visitId, + event_id: eventId, + country: country, + region: country && region ? (region.includes('-') ? region : `${country}-${region}`) : null, + city: city, + url_path: urlPath?.substring(0, URL_LENGTH), + url_query: urlQuery?.substring(0, URL_LENGTH), + utm_source: utmSource, + utm_medium: utmMedium, + utm_campaign: utmCampaign, + utm_content: utmContent, + utm_term: utmTerm, + referrer_path: referrerPath?.substring(0, URL_LENGTH), + referrer_query: referrerQuery?.substring(0, URL_LENGTH), + referrer_domain: referrerDomain?.substring(0, URL_LENGTH), + page_title: pageTitle?.substring(0, PAGE_TITLE_LENGTH), + gclid: gclid, + fbclid: fbclid, + msclkid: msclkid, + ttclid: ttclid, + li_fat_id: lifatid, + twclid: twclid, + event_type: eventType, + event_name: eventName ? eventName?.substring(0, EVENT_NAME_LENGTH) : null, + tag: tag, + distinct_id: distinctId, + created_at: getUTCString(createdAt), + browser, + os, + device, + screen, + language, + hostname, + }; + + if (kafka.enabled) { + await sendMessage('event', message); + } else { + await insert('website_event', [message]); + } + + if (eventData) { + await saveEventData({ + websiteId, + sessionId, + eventId, + urlPath: urlPath?.substring(0, URL_LENGTH), + eventName: eventName?.substring(0, EVENT_NAME_LENGTH), + eventData, + createdAt, + }); + } +} diff --git a/src/queries/sql/events/saveEventData.ts b/src/queries/sql/events/saveEventData.ts new file mode 100644 index 0000000..b8b0e02 --- /dev/null +++ b/src/queries/sql/events/saveEventData.ts @@ -0,0 +1,79 @@ +import clickhouse from '@/lib/clickhouse'; +import { DATA_TYPE } from '@/lib/constants'; +import { uuid } from '@/lib/crypto'; +import { flattenJSON, getStringValue } from '@/lib/data'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import kafka from '@/lib/kafka'; +import prisma from '@/lib/prisma'; +import type { DynamicData } from '@/lib/types'; + +export interface SaveEventDataArgs { + websiteId: string; + eventId: string; + sessionId?: string; + urlPath?: string; + eventName?: string; + eventData: DynamicData; + createdAt?: Date; +} + +export async function saveEventData(data: SaveEventDataArgs) { + return runQuery({ + [PRISMA]: () => relationalQuery(data), + [CLICKHOUSE]: () => clickhouseQuery(data), + }); +} + +async function relationalQuery(data: SaveEventDataArgs) { + const { websiteId, eventId, eventData, createdAt } = data; + + const jsonKeys = flattenJSON(eventData); + + // id, websiteEventId, eventStringValue + const flattenedData = jsonKeys.map(a => ({ + id: uuid(), + websiteEventId: eventId, + websiteId, + dataKey: a.key, + stringValue: getStringValue(a.value, a.dataType), + numberValue: a.dataType === DATA_TYPE.number ? a.value : null, + dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null, + dataType: a.dataType, + createdAt, + })); + + await prisma.client.eventData.createMany({ + data: flattenedData, + }); +} + +async function clickhouseQuery(data: SaveEventDataArgs) { + const { websiteId, sessionId, eventId, urlPath, eventName, eventData, createdAt } = data; + + const { insert, getUTCString } = clickhouse; + const { sendMessage } = kafka; + + const jsonKeys = flattenJSON(eventData); + + const messages = jsonKeys.map(({ key, value, dataType }) => { + return { + website_id: websiteId, + session_id: sessionId, + event_id: eventId, + url_path: urlPath, + event_name: eventName, + data_key: key, + data_type: dataType, + string_value: getStringValue(value, dataType), + number_value: dataType === DATA_TYPE.number ? value : null, + date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null, + created_at: getUTCString(createdAt), + }; + }); + + if (kafka.enabled) { + await sendMessage('event_data', messages); + } else { + await insert('event_data', messages); + } +} diff --git a/src/queries/sql/events/saveRevenue.ts b/src/queries/sql/events/saveRevenue.ts new file mode 100644 index 0000000..a38df83 --- /dev/null +++ b/src/queries/sql/events/saveRevenue.ts @@ -0,0 +1,36 @@ +import { uuid } from '@/lib/crypto'; +import { PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; + +export interface SaveRevenueArgs { + websiteId: string; + sessionId: string; + eventId: string; + eventName: string; + currency: string; + revenue: number; + createdAt: Date; +} + +export async function saveRevenue(data: SaveRevenueArgs) { + return runQuery({ + [PRISMA]: () => relationalQuery(data), + }); +} + +async function relationalQuery(data: SaveRevenueArgs) { + const { websiteId, sessionId, eventId, eventName, currency, revenue, createdAt } = data; + + await prisma.client.revenue.create({ + data: { + id: uuid(), + websiteId, + sessionId, + eventId, + eventName, + currency, + revenue, + createdAt, + }, + }); +} diff --git a/src/queries/sql/getActiveVisitors.ts b/src/queries/sql/getActiveVisitors.ts new file mode 100644 index 0000000..d763c12 --- /dev/null +++ b/src/queries/sql/getActiveVisitors.ts @@ -0,0 +1,50 @@ +import { subMinutes } from 'date-fns'; +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; + +const FUNCTION_NAME = 'getActiveVisitors'; + +export async function getActiveVisitors(...args: [websiteId: string]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string) { + const { rawQuery } = prisma; + const startDate = subMinutes(new Date(), 5); + + const result = await rawQuery( + ` + select count(distinct session_id) as "visitors" + from website_event + where website_id = {{websiteId::uuid}} + and created_at >= {{startDate}} + `, + { websiteId, startDate }, + FUNCTION_NAME, + ); + + return result?.[0] ?? null; +} + +async function clickhouseQuery(websiteId: string): Promise<{ x: number }> { + const { rawQuery } = clickhouse; + const startDate = subMinutes(new Date(), 5); + + const result = await rawQuery( + ` + select + count(distinct session_id) as "visitors" + from website_event + where website_id = {websiteId:UUID} + and created_at >= {startDate:DateTime64} + `, + { websiteId, startDate }, + FUNCTION_NAME, + ); + + return result[0] ?? null; +} diff --git a/src/queries/sql/getChannelExpandedMetrics.ts b/src/queries/sql/getChannelExpandedMetrics.ts new file mode 100644 index 0000000..33640d5 --- /dev/null +++ b/src/queries/sql/getChannelExpandedMetrics.ts @@ -0,0 +1,190 @@ +import clickhouse from '@/lib/clickhouse'; +import { + EMAIL_DOMAINS, + PAID_AD_PARAMS, + SEARCH_DOMAINS, + SHOPPING_DOMAINS, + SOCIAL_DOMAINS, + VIDEO_DOMAINS, +} from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getChannelExpandedMetrics'; + +export interface ChannelExpandedMetricsParameters { + limit?: number | string; + offset?: number | string; +} + +export interface ChannelExpandedMetricsData { + name: string; + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; +} + +export async function getChannelExpandedMetrics( + ...args: [websiteId: string, filters?: QueryFilters] +): Promise<ChannelExpandedMetricsData[]> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + filters: QueryFilters, +): Promise<ChannelExpandedMetricsData[]> { + const { rawQuery, parseFilters, getTimestampDiffSQL } = prisma; + const { queryParams, filterQuery, joinSessionQuery, cohortQuery, dateQuery } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + WITH prefix AS ( + select case when website_event.utm_medium LIKE 'p%' OR + website_event.utm_medium LIKE '%ppc%' OR + website_event.utm_medium LIKE '%retargeting%' OR + website_event.utm_medium LIKE '%paid%' then 'paid' else 'organic' end prefix, + website_event.referrer_domain, + website_event.url_query, + website_event.utm_medium, + website_event.utm_source, + website_event.session_id, + website_event.visit_id, + count(*) c, + min(website_event.created_at) min_time, + max(website_event.created_at) max_time + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.event_type != 2 + ${dateQuery} + ${filterQuery} + group by prefix, + website_event.referrer_domain, + website_event.url_query, + website_event.utm_medium, + website_event.utm_source, + website_event.session_id, + website_event.visit_id), + + channels as ( + select case + when referrer_domain = '' and url_query = '' then 'direct' + when ${toPostgresPositionClause('url_query', PAID_AD_PARAMS)} then 'paidAds' + when ${toPostgresPositionClause('utm_medium', ['referral', 'app', 'link'])} then 'referral' + when utm_medium ilike '%affiliate%' then 'affiliate' + when utm_medium ilike '%sms%' or utm_source ilike '%sms%' then 'sms' + when ${toPostgresPositionClause('referrer_domain', SEARCH_DOMAINS)} or utm_medium ilike '%organic%' then concat(prefix, 'Search') + when ${toPostgresPositionClause('referrer_domain', SOCIAL_DOMAINS)} then concat(prefix, 'Social') + when ${toPostgresPositionClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email' + when ${toPostgresPositionClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping') + when ${toPostgresPositionClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video') + else '' end AS name, + session_id, + visit_id, + c, + min_time, + max_time + from prefix) + + select + name, + sum(c) as "pageviews", + count(distinct session_id) as "visitors", + count(distinct visit_id) as "visits", + sum(case when c = 1 then 1 else 0 end) as "bounces", + sum(${getTimestampDiffSQL('min_time', 'max_time')}) as "totaltime" + from channels + where name != '' + group by name + order by visitors desc, visits desc + `, + queryParams, + FUNCTION_NAME, + ).then(results => results.map(item => ({ ...item, y: Number(item.y) }))); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<ChannelExpandedMetricsData[]> { + const { rawQuery, parseFilters } = clickhouse; + const { queryParams, filterQuery, cohortQuery } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + name, + sum(t.c) as "pageviews", + uniq(t.session_id) as "visitors", + uniq(t.visit_id) as "visits", + sum(if(t.c = 1, 1, 0)) as "bounces", + sum(max_time-min_time) as "totaltime" + from ( + select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, + case + when referrer_domain = '' and url_query = '' then 'direct' + when multiSearchAny(url_query, [${toClickHouseStringArray( + PAID_AD_PARAMS, + )}]) != 0 then 'paidAds' + when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral' + when position(utm_medium, 'affiliate') > 0 then 'affiliate' + when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms' + when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + SEARCH_DOMAINS, + )}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search') + when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + SOCIAL_DOMAINS, + )}]) != 0 then concat(prefix, 'Social') + when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + EMAIL_DOMAINS, + )}]) != 0 or position(utm_medium, 'mail') > 0 then 'email' + when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + SHOPPING_DOMAINS, + )}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping') + when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + VIDEO_DOMAINS, + )}]) != 0 or position(utm_medium, 'video') > 0 then concat(prefix, 'Video') + else '' end AS name, + session_id, + visit_id, + count(*) c, + min(created_at) min_time, + max(created_at) max_time + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + and name != '' + ${filterQuery} + group by prefix, name, session_id, visit_id + ) as t + group by name + order by visitors desc, visits desc; + `, + queryParams, + FUNCTION_NAME, + ); +} + +function toClickHouseStringArray(arr: string[]): string { + return arr.map(p => `'${p.replace(/'/g, "\\'")}'`).join(', '); +} + +function toPostgresPositionClause(column: string, arr: string[]) { + return arr.map(val => `${column} ilike '%${val.replace(/'/g, "''")}%'`).join(' OR\n '); +} diff --git a/src/queries/sql/getChannelMetrics.ts b/src/queries/sql/getChannelMetrics.ts new file mode 100644 index 0000000..78e4142 --- /dev/null +++ b/src/queries/sql/getChannelMetrics.ts @@ -0,0 +1,142 @@ +import clickhouse from '@/lib/clickhouse'; +import { + EMAIL_DOMAINS, + PAID_AD_PARAMS, + SEARCH_DOMAINS, + SHOPPING_DOMAINS, + SOCIAL_DOMAINS, + VIDEO_DOMAINS, +} from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getChannelMetrics'; + +export async function getChannelMetrics(...args: [websiteId: string, filters?: QueryFilters]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { rawQuery, parseFilters } = prisma; + const { queryParams, filterQuery, joinSessionQuery, cohortQuery, dateQuery } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + WITH prefix AS ( + select case when website_event.utm_medium LIKE 'p%' OR + website_event.utm_medium LIKE '%ppc%' OR + website_event.utm_medium LIKE '%retargeting%' OR + website_event.utm_medium LIKE '%paid%' then 'paid' else 'organic' end prefix, + website_event.referrer_domain, + website_event.url_query, + website_event.utm_medium, + website_event.utm_source, + website_event.session_id + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.event_type != 2 + ${dateQuery} + ${filterQuery}), + + channels as ( + select case + when referrer_domain = '' and url_query = '' then 'direct' + when ${toPostgresLikeClause('url_query', PAID_AD_PARAMS)} then 'paidAds' + when ${toPostgresLikeClause('utm_medium', ['referral', 'app', 'link'])} then 'referral' + when utm_medium ilike '%affiliate%' then 'affiliate' + when utm_medium ilike '%sms%' or utm_source ilike '%sms%' then 'sms' + when ${toPostgresLikeClause('referrer_domain', SEARCH_DOMAINS)} or utm_medium ilike '%organic%' then concat(prefix, 'Search') + when ${toPostgresLikeClause('referrer_domain', SOCIAL_DOMAINS)} then concat(prefix, 'Social') + when ${toPostgresLikeClause('referrer_domain', EMAIL_DOMAINS)} or utm_medium ilike '%mail%' then 'email' + when ${toPostgresLikeClause('referrer_domain', SHOPPING_DOMAINS)} or utm_medium ilike '%shop%' then concat(prefix, 'Shopping') + when ${toPostgresLikeClause('referrer_domain', VIDEO_DOMAINS)} or utm_medium ilike '%video%' then concat(prefix, 'Video') + else '' end AS x, + count(distinct session_id) y + from prefix + group by 1 + order by y desc) + + select x, sum(y) y + from channels + where x != '' + group by x + order by y desc; + `, + queryParams, + FUNCTION_NAME, + ).then(results => results.map(item => ({ ...item, y: Number(item.y) }))); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<{ x: string; y: number }[]> { + const { rawQuery, parseFilters } = clickhouse; + const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters({ + ...filters, + websiteId, + }); + + const sql = ` + WITH channels as ( + select case when multiSearchAny(utm_medium, ['cp', 'ppc', 'retargeting', 'paid']) != 0 then 'paid' else 'organic' end prefix, + case + when referrer_domain = '' and url_query = '' then 'direct' + when multiSearchAny(url_query, [${toClickHouseStringArray( + PAID_AD_PARAMS, + )}]) != 0 then 'paidAds' + when multiSearchAny(utm_medium, ['referral', 'app','link']) != 0 then 'referral' + when position(utm_medium, 'affiliate') > 0 then 'affiliate' + when position(utm_medium, 'sms') > 0 or position(utm_source, 'sms') > 0 then 'sms' + when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + SEARCH_DOMAINS, + )}]) != 0 or position(utm_medium, 'organic') > 0 then concat(prefix, 'Search') + when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + SOCIAL_DOMAINS, + )}]) != 0 then concat(prefix, 'Social') + when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + EMAIL_DOMAINS, + )}]) != 0 or position(utm_medium, 'mail') > 0 then 'email' + when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + SHOPPING_DOMAINS, + )}]) != 0 or position(utm_medium, 'shop') > 0 then concat(prefix, 'Shopping') + when multiSearchAny(referrer_domain, [${toClickHouseStringArray( + VIDEO_DOMAINS, + )}]) != 0 or position(utm_medium, 'video') > 0 then concat(prefix, 'Video') + else '' end AS x, + count(distinct session_id) y + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and event_type != 2 + ${dateQuery} + ${filterQuery} + group by 1, 2 + order by y desc) + + select x, sum(y) y + from channels + where x != '' + group by x + order by y desc; + `; + + return rawQuery(sql, queryParams, FUNCTION_NAME); +} + +function toClickHouseStringArray(arr: string[]): string { + return arr.map(p => `'${p.replace(/'/g, "\\'")}'`).join(', '); +} + +function toPostgresLikeClause(column: string, arr: string[]) { + return arr.map(val => `${column} ilike '%${val.replace(/'/g, "''")}%'`).join(' OR\n '); +} diff --git a/src/queries/sql/getRealtimeActivity.ts b/src/queries/sql/getRealtimeActivity.ts new file mode 100644 index 0000000..075b65e --- /dev/null +++ b/src/queries/sql/getRealtimeActivity.ts @@ -0,0 +1,80 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getRealtimeActivity'; + +export async function getRealtimeActivity(...args: [websiteId: string, filters: QueryFilters]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { rawQuery, parseFilters } = prisma; + const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + website_event.session_id as "sessionId", + website_event.event_name as "eventName", + website_event.created_at as "createdAt", + session.browser, + session.os, + session.device, + session.country, + website_event.url_path as "urlPath", + website_event.referrer_domain as "referrerDomain" + from website_event + ${cohortQuery} + inner join session + on session.session_id = website_event.session_id + and session.website_id = website_event.website_id + where website_event.website_id = {{websiteId::uuid}} + ${filterQuery} + ${dateQuery} + order by website_event.created_at desc + limit 100 + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery(websiteId: string, filters: QueryFilters): Promise<{ x: number }> { + const { rawQuery, parseFilters } = clickhouse; + const { queryParams, filterQuery, cohortQuery, dateQuery } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + session_id as sessionId, + event_name as eventName, + created_at as createdAt, + browser, + os, + device, + country, + url_path as urlPath, + referrer_domain as referrerDomain + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + ${filterQuery} + ${dateQuery} + order by createdAt desc + limit 100 + `, + queryParams, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/getRealtimeData.ts b/src/queries/sql/getRealtimeData.ts new file mode 100644 index 0000000..4b97cb0 --- /dev/null +++ b/src/queries/sql/getRealtimeData.ts @@ -0,0 +1,78 @@ +import type { QueryFilters } from '@/lib/types'; +import { getRealtimeActivity } from '@/queries/sql/getRealtimeActivity'; +import { getPageviewStats } from '@/queries/sql/pageviews/getPageviewStats'; +import { getSessionStats } from '@/queries/sql/sessions/getSessionStats'; + +function increment(data: object, key: string) { + if (key) { + if (!data[key]) { + data[key] = 1; + } else { + data[key] += 1; + } + } +} + +export async function getRealtimeData(websiteId: string, filters: QueryFilters) { + const [activity, pageviews, sessions] = await Promise.all([ + getRealtimeActivity(websiteId, filters), + getPageviewStats(websiteId, filters), + getSessionStats(websiteId, filters), + ]); + + const uniques = new Set(); + + const { countries, urls, referrers, events } = activity.reverse().reduce( + ( + obj: { countries: any; urls: any; referrers: any; events: any }, + event: { + sessionId: string; + urlPath: string; + referrerDomain: string; + country: string; + eventName: string; + }, + ) => { + const { countries, urls, referrers, events } = obj; + const { sessionId, urlPath, referrerDomain, country, eventName } = event; + + if (!uniques.has(sessionId)) { + uniques.add(sessionId); + increment(countries, country); + + events.push({ __type: 'session', ...event }); + } + + increment(urls, urlPath); + increment(referrers, referrerDomain); + + events.push({ __type: eventName ? 'event' : 'pageview', ...event }); + + return obj; + }, + { + countries: {}, + urls: {}, + referrers: {}, + events: [], + }, + ); + + return { + countries, + urls, + referrers, + events: events.reverse(), + series: { + views: pageviews, + visitors: sessions, + }, + totals: { + views: pageviews.reduce((sum: number, { y }: { y: number }) => Number(sum) + Number(y), 0), + visitors: sessions.reduce((sum: number, { y }: { y: number }) => Number(sum) + Number(y), 0), + events: activity.filter(e => e.eventName).length, + countries: Object.keys(countries).length, + }, + timestamp: Date.now(), + }; +} diff --git a/src/queries/sql/getValues.ts b/src/queries/sql/getValues.ts new file mode 100644 index 0000000..cc6bb7d --- /dev/null +++ b/src/queries/sql/getValues.ts @@ -0,0 +1,129 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getValues'; + +export async function getValues( + ...args: [websiteId: string, column: string, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, column: string, filters: QueryFilters) { + const { rawQuery, getSearchSQL } = prisma; + const params = {}; + const { startDate, endDate, search } = filters; + + let searchQuery = ''; + let excludeDomain = ''; + + if (column === 'referrer_domain') { + excludeDomain = `and website_event.referrer_domain != website_event.hostname + and website_event.referrer_domain != ''`; + } + + if (search) { + if (decodeURIComponent(search).includes(',')) { + searchQuery = `AND (${decodeURIComponent(search) + .split(',') + .slice(0, 5) + .map((value: string, index: number) => { + const key = `search${index}`; + + params[key] = value; + + return getSearchSQL(column, key).replace('and ', ''); + }) + .join(' OR ')})`; + } else { + searchQuery = getSearchSQL(column); + } + } + + return rawQuery( + ` + select ${column} as "value", count(*) as "count" + from website_event + inner join session + on session.session_id = website_event.session_id + and session.website_id = website_event.website_id + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${searchQuery} + ${excludeDomain} + group by 1 + order by 2 desc + limit 10 + `, + { + websiteId, + startDate, + endDate, + search: `%${search}%`, + ...params, + }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery(websiteId: string, column: string, filters: QueryFilters) { + const { rawQuery, getSearchSQL } = clickhouse; + const params = {}; + const { startDate, endDate, search } = filters; + + let searchQuery = ''; + let excludeDomain = ''; + + if (column === 'referrer_domain') { + excludeDomain = `and referrer_domain != hostname and referrer_domain != ''`; + } + + if (search) { + searchQuery = `and positionCaseInsensitive(${column}, {search:String}) > 0`; + } + + if (search) { + if (decodeURIComponent(search).includes(',')) { + searchQuery = `AND (${decodeURIComponent(search) + .split(',') + .slice(0, 5) + .map((value: string, index: number) => { + const key = `search${index}`; + + params[key] = value; + + return getSearchSQL(column, key).replace('and ', ''); + }) + .join(' OR ')})`; + } else { + searchQuery = getSearchSQL(column); + } + } + + return rawQuery( + ` + select ${column} as "value", count(*) as "count" + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${searchQuery} + ${excludeDomain} + group by 1 + order by 2 desc + limit 10 + `, + { + websiteId, + startDate, + endDate, + search, + ...params, + }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/getWebsiteDateRange.ts b/src/queries/sql/getWebsiteDateRange.ts new file mode 100644 index 0000000..d6333ad --- /dev/null +++ b/src/queries/sql/getWebsiteDateRange.ts @@ -0,0 +1,55 @@ +import clickhouse from '@/lib/clickhouse'; +import { DEFAULT_RESET_DATE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; + +export async function getWebsiteDateRange(...args: [websiteId: string]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string) { + const { rawQuery, parseFilters } = prisma; + const { queryParams } = parseFilters({ + startDate: new Date(DEFAULT_RESET_DATE), + websiteId, + }); + + const result = await rawQuery( + ` + select + min(created_at) as "startDate", + max(created_at) as "endDate" + from website_event + where website_id = {{websiteId::uuid}} + and created_at >= {{startDate}} + `, + queryParams, + ); + + return result[0] ?? null; +} + +async function clickhouseQuery(websiteId: string) { + const { rawQuery, parseFilters } = clickhouse; + const { queryParams } = parseFilters({ + startDate: new Date(DEFAULT_RESET_DATE), + websiteId, + }); + + const result = await rawQuery( + ` + select + min(created_at) as startDate, + max(created_at) as endDate + from website_event_stats_hourly + where website_id = {websiteId:UUID} + and created_at >= {startDate:DateTime64} + `, + queryParams, + ); + + return result[0] ?? null; +} diff --git a/src/queries/sql/getWebsiteStats.ts b/src/queries/sql/getWebsiteStats.ts new file mode 100644 index 0000000..6906839 --- /dev/null +++ b/src/queries/sql/getWebsiteStats.ts @@ -0,0 +1,128 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getWebsiteStats'; + +export interface WebsiteStatsData { + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; +} + +export async function getWebsiteStats( + ...args: [websiteId: string, filters: QueryFilters] +): Promise<WebsiteStatsData[]> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + filters: QueryFilters, +): Promise<WebsiteStatsData[]> { + const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + cast(coalesce(sum(t.c), 0) as bigint) as "pageviews", + count(distinct t.session_id) as "visitors", + count(distinct t.visit_id) as "visits", + coalesce(sum(case when t.c = 1 then 1 else 0 end), 0) as "bounces", + cast(coalesce(sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}), 0) as bigint) as "totaltime" + from ( + select + website_event.session_id, + website_event.visit_id, + count(*) as "c", + min(website_event.created_at) as "min_time", + max(website_event.created_at) as "max_time" + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type != 2 + ${filterQuery} + group by 1, 2 + ) as t + `, + queryParams, + FUNCTION_NAME, + ).then(result => result?.[0]); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<WebsiteStatsData[]> { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + let sql = ''; + + if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) { + sql = ` + select + sum(t.c) as "pageviews", + uniq(t.session_id) as "visitors", + uniq(t.visit_id) as "visits", + sum(if(t.c = 1, 1, 0)) as "bounces", + sum(max_time-min_time) as "totaltime" + from ( + select + session_id, + visit_id, + count(*) c, + min(created_at) min_time, + max(created_at) max_time + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${filterQuery} + group by session_id, visit_id + ) as t; + `; + } else { + sql = ` + select + sum(t.c) as "pageviews", + uniq(session_id) as "visitors", + uniq(visit_id) as "visits", + sumIf(1, t.c = 1) as "bounces", + sum(max_time-min_time) as "totaltime" + from (select + session_id, + visit_id, + sum(views) c, + min(min_time) min_time, + max(max_time) max_time + from website_event_stats_hourly "website_event" + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${filterQuery} + group by session_id, visit_id + ) as t; + `; + } + + return rawQuery(sql, queryParams, FUNCTION_NAME).then(result => result?.[0]); +} diff --git a/src/queries/sql/getWeeklyTraffic.ts b/src/queries/sql/getWeeklyTraffic.ts new file mode 100644 index 0000000..7bbe78a --- /dev/null +++ b/src/queries/sql/getWeeklyTraffic.ts @@ -0,0 +1,97 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getWeeklyTraffic'; + +export async function getWeeklyTraffic(...args: [websiteId: string, filters: QueryFilters]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const timezone = 'utc'; + const { rawQuery, getDateWeeklySQL, parseFilters } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + ${getDateWeeklySQL('website_event.created_at', timezone)} as time, + count(distinct website_event.session_id) as value + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by time + order by 2 + `, + queryParams, + FUNCTION_NAME, + ).then(formatResults); +} + +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { + const { timezone = 'utc' } = filters; + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = await parseFilters({ ...filters, websiteId }); + + let sql = ''; + + if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) { + sql = ` + select + formatDateTime(toDateTime(created_at, '${timezone}'), '%w:%H') as time, + count(distinct session_id) as value + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by time + order by time + `; + } else { + sql = ` + select + formatDateTime(toDateTime(created_at, '${timezone}'), '%w:%H') as time, + count(distinct session_id) as value + from website_event_stats_hourly website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by time + order by time + `; + } + + return rawQuery(sql, queryParams, FUNCTION_NAME).then(formatResults); +} + +function formatResults(data: any) { + const days = []; + + for (let i = 0; i < 7; i++) { + days.push([]); + + for (let j = 0; j < 24; j++) { + days[i].push( + Number( + data.find(({ time }) => time === `${i}:${j.toString().padStart(2, '0')}`)?.value || 0, + ), + ); + } + } + + return days; +} diff --git a/src/queries/sql/index.ts b/src/queries/sql/index.ts new file mode 100644 index 0000000..1573bde --- /dev/null +++ b/src/queries/sql/index.ts @@ -0,0 +1,41 @@ +export * from './events/getEventDataEvents'; +export * from './events/getEventDataFields'; +export * from './events/getEventDataProperties'; +export * from './events/getEventDataStats'; +export * from './events/getEventDataUsage'; +export * from './events/getEventDataValues'; +export * from './events/getEventExpandedMetrics'; +export * from './events/getEventMetrics'; +export * from './events/getEventStats'; +export * from './events/getEventUsage'; +export * from './events/getWebsiteEvents'; +export * from './events/saveEvent'; +export * from './getActiveVisitors'; +export * from './getChannelExpandedMetrics'; +export * from './getChannelMetrics'; +export * from './getRealtimeActivity'; +export * from './getRealtimeData'; +export * from './getValues'; +export * from './getWebsiteDateRange'; +export * from './getWebsiteStats'; +export * from './getWeeklyTraffic'; +export * from './pageviews/getPageviewExpandedMetrics'; +export * from './pageviews/getPageviewMetrics'; +export * from './pageviews/getPageviewStats'; +export * from './reports/getBreakdown'; +export * from './reports/getFunnel'; +export * from './reports/getJourney'; +export * from './reports/getRetention'; +export * from './reports/getUTM'; +export * from './sessions/createSession'; +export * from './sessions/getSessionActivity'; +export * from './sessions/getSessionData'; +export * from './sessions/getSessionDataProperties'; +export * from './sessions/getSessionDataValues'; +export * from './sessions/getSessionExpandedMetrics'; +export * from './sessions/getSessionMetrics'; +export * from './sessions/getSessionStats'; +export * from './sessions/getWebsiteSession'; +export * from './sessions/getWebsiteSessionStats'; +export * from './sessions/getWebsiteSessions'; +export * from './sessions/saveSessionData'; diff --git a/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts new file mode 100644 index 0000000..986d7d5 --- /dev/null +++ b/src/queries/sql/pageviews/getPageviewExpandedMetrics.ts @@ -0,0 +1,227 @@ +import clickhouse from '@/lib/clickhouse'; +import { FILTER_COLUMNS, GROUPED_DOMAINS, SESSION_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getPageviewExpandedMetrics'; + +export interface PageviewExpandedMetricsParameters { + type: string; + limit?: number | string; + offset?: number | string; +} + +export interface PageviewExpandedMetricsData { + name: string; + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; +} + +export async function getPageviewExpandedMetrics( + ...args: [websiteId: string, parameters: PageviewExpandedMetricsParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: PageviewExpandedMetricsParameters, + filters: QueryFilters, +): Promise<PageviewExpandedMetricsData[]> { + const { type, limit = 500, offset = 0 } = parameters; + let column = FILTER_COLUMNS[type] || type; + const { rawQuery, parseFilters, getTimestampDiffSQL } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( + { + ...filters, + websiteId, + }, + { joinSession: SESSION_COLUMNS.includes(type) }, + ); + + let entryExitQuery = ''; + let excludeDomain = ''; + + if (column === 'referrer_domain') { + excludeDomain = `and website_event.referrer_domain != website_event.hostname + and website_event.referrer_domain != ''`; + if (type === 'domain') { + column = toPostgresGroupedReferrer(GROUPED_DOMAINS); + } + } + + if (type === 'entry' || type === 'exit') { + const aggregrate = type === 'entry' ? 'min' : 'max'; + + entryExitQuery = ` + join ( + select visit_id, + ${aggregrate}(created_at) target_created_at + from website_event + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type != 2 + group by visit_id + ) x + on x.visit_id = website_event.visit_id + and x.target_created_at = website_event.created_at + `; + } + + return rawQuery( + ` + select + name, + sum(t.c) as "pageviews", + count(distinct t.session_id) as "visitors", + count(distinct t.visit_id) as "visits", + sum(case when t.c = 1 then 1 else 0 end) as "bounces", + sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" + from ( + select + ${column} as name, + website_event.session_id, + website_event.visit_id, + count(*) as "c", + min(website_event.created_at) as "min_time", + max(website_event.created_at) as "max_time" + from website_event + ${cohortQuery} + ${joinSessionQuery} + ${entryExitQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type != 2 + ${excludeDomain} + ${filterQuery} + group by ${column}, website_event.session_id, website_event.visit_id + ) as t + where name != '' + group by name + order by visitors desc, visits desc + limit ${limit} + offset ${offset} + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: PageviewExpandedMetricsParameters, + filters: QueryFilters, +): Promise<{ x: string; y: number }[]> { + const { type, limit = 500, offset = 0 } = parameters; + let column = FILTER_COLUMNS[type] || type; + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + let excludeDomain = ''; + let entryExitQuery = ''; + + if (column === 'referrer_domain') { + excludeDomain = `and referrer_domain != hostname and referrer_domain != ''`; + if (type === 'domain') { + column = toClickHouseGroupedReferrer(GROUPED_DOMAINS); + } + } + + if (type === 'entry' || type === 'exit') { + const aggregrate = type === 'entry' ? 'argMin' : 'argMax'; + column = `x.${column}`; + + entryExitQuery = ` + JOIN (select visit_id, + ${aggregrate}(url_path, created_at) url_path + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + group by visit_id) x + ON x.visit_id = website_event.visit_id`; + } + + return rawQuery( + ` + select + name, + sum(t.c) as "pageviews", + uniq(t.session_id) as "visitors", + uniq(t.visit_id) as "visits", + sum(if(t.c = 1, 1, 0)) as "bounces", + sum(max_time-min_time) as "totaltime" + from ( + select + ${column} name, + session_id, + visit_id, + count(*) c, + min(created_at) min_time, + max(created_at) max_time + from website_event + ${cohortQuery} + ${entryExitQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + and name != '' + ${excludeDomain} + ${filterQuery} + group by name, session_id, visit_id + ) as t + group by name + order by visitors desc, visits desc + limit ${limit} + offset ${offset} + `, + { ...queryParams, ...parameters }, + FUNCTION_NAME, + ); +} + +export function toClickHouseGroupedReferrer( + domains: any[], + column: string = 'referrer_domain', +): string { + return [ + 'CASE', + ...domains.map(group => { + const matches = Array.isArray(group.match) ? group.match : [group.match]; + const formattedArray = matches.map(m => `'${m}'`).join(', '); + return ` WHEN multiSearchAny(${column}, [${formattedArray}]) != 0 THEN '${group.domain}'`; + }), + " ELSE 'Other'", + 'END', + ].join('\n'); +} + +export function toPostgresGroupedReferrer( + domains: any[], + column: string = 'referrer_domain', +): string { + return [ + 'CASE', + ...domains.map(group => { + const matches = Array.isArray(group.match) ? group.match : [group.match]; + + return `WHEN ${toPostgresLikeClause(column, matches)} THEN '${group.domain}'`; + }), + " ELSE 'Other'", + 'END', + ].join('\n'); +} + +function toPostgresLikeClause(column: string, arr: string[]) { + return arr.map(val => `${column} ilike '%${val.replace(/'/g, "''")}%'`).join(' OR\n '); +} diff --git a/src/queries/sql/pageviews/getPageviewMetrics.ts b/src/queries/sql/pageviews/getPageviewMetrics.ts new file mode 100644 index 0000000..9d4f627 --- /dev/null +++ b/src/queries/sql/pageviews/getPageviewMetrics.ts @@ -0,0 +1,191 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getPageviewMetrics'; + +export interface PageviewMetricsParameters { + type: string; + limit?: number | string; + offset?: number | string; +} + +export interface PageviewMetricsData { + x: string; + y: number; +} + +export async function getPageviewMetrics( + ...args: [websiteId: string, parameters: PageviewMetricsParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: PageviewMetricsParameters, + filters: QueryFilters, +): Promise<PageviewMetricsData[]> { + const { type, limit = 500, offset = 0 } = parameters; + let column = FILTER_COLUMNS[type] || type; + const { rawQuery, parseFilters } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( + { + ...filters, + websiteId, + }, + { joinSession: SESSION_COLUMNS.includes(type) }, + ); + + let entryExitQuery = ''; + let excludeDomain = ''; + + if (column === 'referrer_domain') { + excludeDomain = `and website_event.referrer_domain != website_event.hostname + and website_event.referrer_domain != ''`; + } + + if (type === 'entry' || type === 'exit') { + const order = type === 'entry' ? 'asc' : 'desc'; + column = `x.${column}`; + + entryExitQuery = ` + join ( + select distinct on (visit_id) + visit_id, + url_path + from website_event + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type != 2 + order by visit_id, created_at ${order} + ) x + on x.visit_id = website_event.visit_id + `; + } + + return rawQuery( + ` + select ${column} x, + count(distinct website_event.session_id) as y + from website_event + ${cohortQuery} + ${joinSessionQuery} + ${entryExitQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type != 2 + ${excludeDomain} + ${filterQuery} + group by 1 + order by 2 desc + limit ${limit} + offset ${offset} + `, + { ...queryParams, ...parameters }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: PageviewMetricsParameters, + filters: QueryFilters, +): Promise<{ x: string; y: number }[]> { + const { type, limit = 500, offset = 0 } = parameters; + let column = FILTER_COLUMNS[type] || type; + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + let sql = ''; + let excludeDomain = ''; + + if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) { + let entryExitQuery = ''; + + if (column === 'referrer_domain') { + excludeDomain = `and referrer_domain != hostname and referrer_domain != ''`; + } + + if (type === 'entry' || type === 'exit') { + const aggregrate = type === 'entry' ? 'argMin' : 'argMax'; + column = `x.${column}`; + + entryExitQuery = ` + JOIN (select visit_id, + ${aggregrate}(url_path, created_at) url_path + from website_event + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + group by visit_id) x + ON x.visit_id = website_event.visit_id`; + } + + sql = ` + select ${column} x, + uniq(website_event.session_id) as y + from website_event + ${cohortQuery} + ${entryExitQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${excludeDomain} + ${filterQuery} + group by x + order by y desc + limit ${limit} + offset ${offset} + `; + } else { + let groupByQuery = ''; + let columnQuery = `arrayJoin(${column})`; + + if (column === 'referrer_domain') { + excludeDomain = `and t != ''`; + } + + if (type === 'entry') { + columnQuery = `argMinMerge(entry_url)`; + } + + if (type === 'exit') { + columnQuery = `argMaxMerge(exit_url)`; + } + + if (type === 'entry' || type === 'exit') { + groupByQuery = 'group by s'; + } + + sql = ` + select g.t as x, + uniq(s) as y + from ( + select session_id s, + ${columnQuery} as t + from website_event_stats_hourly as website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${excludeDomain} + ${filterQuery} + ${groupByQuery}) as g + group by x + order by y desc + limit ${limit} + offset ${offset} + `; + } + + return rawQuery(sql, { ...queryParams, ...parameters }, FUNCTION_NAME); +} diff --git a/src/queries/sql/pageviews/getPageviewStats.ts b/src/queries/sql/pageviews/getPageviewStats.ts new file mode 100644 index 0000000..251d5b1 --- /dev/null +++ b/src/queries/sql/pageviews/getPageviewStats.ts @@ -0,0 +1,98 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getPageviewStats'; + +export async function getPageviewStats(...args: [websiteId: string, filters: QueryFilters]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { timezone = 'utc', unit = 'day' } = filters; + const { getDateSQL, parseFilters, rawQuery } = prisma; + const { filterQuery, cohortQuery, joinSessionQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + ${getDateSQL('website_event.created_at', unit, timezone)} x, + count(*) y + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type != 2 + ${filterQuery} + group by 1 + order by 1 + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<{ x: string; y: number }[]> { + const { timezone = 'UTC', unit = 'day' } = filters; + const { parseFilters, rawQuery, getDateSQL } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + let sql = ''; + + if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item)) || unit === 'minute') { + sql = ` + select + g.t as x, + g.y as y + from ( + select + ${getDateSQL('website_event.created_at', unit, timezone)} as t, + count(*) as y + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${filterQuery} + group by t + ) as g + order by t + `; + } else { + sql = ` + select + g.t as x, + g.y as y + from ( + select + ${getDateSQL('website_event.created_at', unit, timezone)} as t, + sum(views) as y + from website_event_stats_hourly as website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${filterQuery} + group by t + ) as g + order by t + `; + } + + return rawQuery(sql, queryParams, FUNCTION_NAME); +} diff --git a/src/queries/sql/reports/getAttribution.ts b/src/queries/sql/reports/getAttribution.ts new file mode 100644 index 0000000..1d04078 --- /dev/null +++ b/src/queries/sql/reports/getAttribution.ts @@ -0,0 +1,514 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface AttributionParameters { + startDate: Date; + endDate: Date; + model: string; + type: string; + step: string; + currency?: string; +} + +export interface AttributionResult { + referrer: { name: string; value: number }[]; + paidAds: { name: string; value: number }[]; + utm_source: { name: string; value: number }[]; + utm_medium: { name: string; value: number }[]; + utm_campaign: { name: string; value: number }[]; + utm_content: { name: string; value: number }[]; + utm_term: { name: string; value: number }[]; + total: { pageviews: number; visitors: number; visits: number }; +} + +export async function getAttribution( + ...args: [websiteId: string, parameters: AttributionParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: AttributionParameters, + filters: QueryFilters, +): Promise<AttributionResult> { + const { model, type, currency } = parameters; + const { rawQuery, parseFilters } = prisma; + const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; + const column = type === 'path' ? 'url_path' : 'event_name'; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + ...parameters, + websiteId, + eventType, + }); + + function getUTMQuery(utmColumn: string) { + return ` + select + coalesce(we.${utmColumn}, '') name, + ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {{websiteId::uuid}} + and we.created_at between {{startDate}} and {{endDate}} + ${currency ? '' : `and we.${utmColumn} != ''`} + group by 1 + order by 2 desc + limit 20`; + } + + const eventQuery = `WITH events AS ( + select distinct + website_event.session_id, + max(website_event.created_at) max_dt + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.${column} = {{step}} + ${filterQuery} + group by 1),`; + + const revenueEventQuery = `WITH events AS ( + select + revenue.session_id, + max(revenue.created_at) max_dt, + sum(revenue.revenue) value + from revenue + join website_event + on website_event.website_id = revenue.website_id + and website_event.session_id = revenue.session_id + and website_event.event_id = revenue.event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${cohortQuery} + ${joinSessionQuery} + where revenue.website_id = {{websiteId::uuid}} + and revenue.created_at between {{startDate}} and {{endDate}} + and revenue.${column} = {{step}} + and revenue.currency = {{currency}} + ${filterQuery} + group by 1),`; + + function getModelQuery(model: string) { + return model === 'first-click' + ? `\n + model AS (select e.session_id, + min(we.created_at) created_at + from events e + join website_event we + on we.session_id = e.session_id + where we.website_id = {{websiteId::uuid}} + and we.created_at between {{startDate}} and {{endDate}} + group by e.session_id)` + : `\n + model AS (select e.session_id, + max(we.created_at) created_at + from events e + join website_event we + on we.session_id = e.session_id + where we.website_id = {{websiteId::uuid}} + and we.created_at between {{startDate}} and {{endDate}} + and we.created_at < e.max_dt + group by e.session_id)`; + } + + const referrerRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + select coalesce(we.referrer_domain, '') name, + ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + join session s + on s.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {{websiteId::uuid}} + and we.created_at between {{startDate}} and {{endDate}} + ${ + currency + ? '' + : `and we.referrer_domain != hostname + and we.referrer_domain != ''` + } + group by 1 + order by 2 desc + limit 20 + `, + queryParams, + ); + + const paidAdsres = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)}, + + results AS ( + select case + when coalesce(gclid, '') != '' then 'Google Ads' + when coalesce(fbclid, '') != '' then 'Facebook / Meta' + when coalesce(msclkid, '') != '' then 'Microsoft Ads' + when coalesce(ttclid, '') != '' then 'TikTok Ads' + when coalesce(li_fat_id, '') != '' then 'LinkedIn Ads' + when coalesce(twclid, '') != '' then 'Twitter Ads (X)' + else '' + end name, + ${currency ? 'sum(e.value)' : 'count(distinct we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {{websiteId::uuid}} + and we.created_at between {{startDate}} and {{endDate}} + group by 1 + order by 2 desc + limit 20) + SELECT * + FROM results + ${currency ? '' : `WHERE name != ''`} + `, + queryParams, + ); + + const sourceRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_source')} + `, + queryParams, + ); + + const mediumRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_medium')} + `, + queryParams, + ); + + const campaignRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_campaign')} + `, + queryParams, + ); + + const contentRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_content')} + `, + queryParams, + ); + + const termRes = await rawQuery( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_term')} + `, + queryParams, + ); + + const totalRes = await rawQuery( + ` + select + count(*) as "pageviews", + count(distinct website_event.session_id) as "visitors", + count(distinct website_event.visit_id) as "visits" + from website_event + ${joinSessionQuery} + ${cohortQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.${column} = {{step}} + ${filterQuery} + `, + queryParams, + ).then(result => result?.[0]); + + return { + referrer: referrerRes, + paidAds: paidAdsres, + utm_source: sourceRes, + utm_medium: mediumRes, + utm_campaign: campaignRes, + utm_content: contentRes, + utm_term: termRes, + total: totalRes, + }; +} + +async function clickhouseQuery( + websiteId: string, + parameters: AttributionParameters, + filters: QueryFilters, +): Promise<AttributionResult> { + const { model, type, currency } = parameters; + const { rawQuery, parseFilters } = clickhouse; + const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; + const column = type === 'path' ? 'url_path' : 'event_name'; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + ...parameters, + websiteId, + eventType, + }); + + function getUTMQuery(utmColumn: string) { + return ` + select + we.${utmColumn} name, + ${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {websiteId:UUID} + and we.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${currency ? '' : `and we.${utmColumn} != ''`} + group by 1 + order by 2 desc + limit 20 + `; + } + + function getModelQuery(model: string) { + if (model === 'first-click') { + return ` + model AS (select e.session_id, + min(we.created_at) created_at + from events e + join website_event we + on we.session_id = e.session_id + where we.website_id = {websiteId:UUID} + and we.created_at between {startDate:DateTime64} and {endDate:DateTime64} + group by e.session_id) + `; + } + + return ` + model AS (select e.session_id, + max(we.created_at) created_at + from events e + join website_event we + on we.session_id = e.session_id + where we.website_id = {websiteId:UUID} + and we.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and we.created_at < e.max_dt + group by e.session_id) + `; + } + + const eventQuery = `WITH events AS ( + select distinct + session_id, + max(created_at) max_dt + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and ${column} = {step:String} + ${filterQuery} + group by 1),`; + + const revenueEventQuery = `WITH events AS ( + select + website_revenue.session_id, + max(website_revenue.created_at) max_dt, + sum(website_revenue.revenue) as value + from website_revenue + join website_event + on website_event.website_id = website_revenue.website_id + and website_event.session_id = website_revenue.session_id + and website_event.event_id = website_revenue.event_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and website_revenue.${column} = {step:String} + and website_revenue.currency = {currency:String} + ${filterQuery} + group by 1),`; + + const referrerRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + select we.referrer_domain name, + ${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {websiteId:UUID} + and we.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${ + currency + ? '' + : `and we.referrer_domain != hostname + and we.referrer_domain != ''` + } + group by 1 + order by 2 desc + limit 20 + `, + queryParams, + ); + + const paidAdsres = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + select multiIf(gclid != '', 'Google Ads', + fbclid != '', 'Facebook / Meta', + msclkid != '', 'Microsoft Ads', + ttclid != '', 'TikTok Ads', + li_fat_id != '', 'LinkedIn Ads', + twclid != '', 'Twitter Ads (X)','') name, + ${currency ? 'sum(e.value)' : 'uniqExact(we.session_id)'} value + from model m + join website_event we + on we.created_at = m.created_at + and we.session_id = m.session_id + ${currency ? 'join events e on e.session_id = m.session_id' : ''} + where we.website_id = {websiteId:UUID} + and we.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${currency ? '' : `and name != ''`} + group by 1 + order by 2 desc + limit 20 + `, + queryParams, + ); + + const sourceRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_source')} + `, + queryParams, + ); + + const mediumRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_medium')} + `, + queryParams, + ); + + const campaignRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_campaign')} + `, + queryParams, + ); + + const contentRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_content')} + `, + queryParams, + ); + + const termRes = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + ${currency ? revenueEventQuery : eventQuery} + ${getModelQuery(model)} + ${getUTMQuery('utm_term')} + `, + queryParams, + ); + + const totalRes = await rawQuery<{ pageviews: number; visitors: number; visits: number }>( + ` + select + count(*) as "pageviews", + uniqExact(session_id) as "visitors", + uniqExact(visit_id) as "visits" + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and ${column} = {step:String} + ${filterQuery} + `, + queryParams, + ).then(result => result?.[0]); + + return { + referrer: referrerRes, + paidAds: paidAdsres, + utm_source: sourceRes, + utm_medium: mediumRes, + utm_campaign: campaignRes, + utm_content: contentRes, + utm_term: termRes, + total: totalRes, + }; +} diff --git a/src/queries/sql/reports/getBreakdown.ts b/src/queries/sql/reports/getBreakdown.ts new file mode 100644 index 0000000..51773d8 --- /dev/null +++ b/src/queries/sql/reports/getBreakdown.ts @@ -0,0 +1,135 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface BreakdownParameters { + startDate: Date; + endDate: Date; + fields: string[]; +} + +export interface BreakdownData { + x: string; + y: number; +} + +export async function getBreakdown( + ...args: [websiteId: string, parameters: BreakdownParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: BreakdownParameters, + filters: QueryFilters, +): Promise<BreakdownData[]> { + const { getTimestampDiffSQL, parseFilters, rawQuery } = prisma; + const { startDate, endDate, fields } = parameters; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( + { + ...filters, + websiteId, + startDate, + endDate, + eventType: EVENT_TYPE.pageView, + }, + { + joinSession: !!fields.find((name: string) => SESSION_COLUMNS.includes(name)), + }, + ); + + return rawQuery( + ` + select + sum(t.c) as "views", + count(distinct t.session_id) as "visitors", + count(distinct t.visit_id) as "visits", + sum(case when t.c = 1 then 1 else 0 end) as "bounces", + sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime", + ${parseFieldsByName(fields)} + from ( + select + ${parseFields(fields)}, + website_event.session_id, + website_event.visit_id, + count(*) as "c", + min(website_event.created_at) as "min_time", + max(website_event.created_at) as "max_time" + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by ${parseFieldsByName(fields)}, + website_event.session_id, website_event.visit_id + ) as t + group by ${parseFieldsByName(fields)} + order by 1 desc, 2 desc + limit 500 + `, + queryParams, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: BreakdownParameters, + filters: QueryFilters, +): Promise<BreakdownData[]> { + const { parseFilters, rawQuery } = clickhouse; + const { startDate, endDate, fields } = parameters; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + eventType: EVENT_TYPE.pageView, + }); + + return rawQuery( + ` + select + sum(t.c) as "views", + count(distinct t.session_id) as "visitors", + count(distinct t.visit_id) as "visits", + sum(if(t.c = 1, 1, 0)) as "bounces", + sum(max_time-min_time) as "totaltime", + ${parseFieldsByName(fields)} + from ( + select + ${parseFields(fields)}, + session_id, + visit_id, + count(*) c, + min(created_at) min_time, + max(created_at) max_time + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by ${parseFieldsByName(fields)}, + session_id, visit_id + ) as t + group by ${parseFieldsByName(fields)} + order by 1 desc, 2 desc + limit 500 + `, + queryParams, + ); +} + +function parseFields(fields: string[]) { + return fields.map(name => `${FILTER_COLUMNS[name]} as "${name}"`).join(','); +} + +function parseFieldsByName(fields: string[]) { + return `${fields.map(name => name).join(',')}`; +} diff --git a/src/queries/sql/reports/getFunnel.ts b/src/queries/sql/reports/getFunnel.ts new file mode 100644 index 0000000..4840123 --- /dev/null +++ b/src/queries/sql/reports/getFunnel.ts @@ -0,0 +1,255 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface FunnelParameters { + startDate: Date; + endDate: Date; + window: number; + steps: { type: string; value: string }[]; +} + +export interface FunnelResult { + value: string; + visitors: number; + dropoff: number; +} + +export async function getFunnel( + ...args: [websiteId: string, parameters: FunnelParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: FunnelParameters, + filters: QueryFilters, +): Promise<FunnelResult[]> { + const { startDate, endDate, window, steps } = parameters; + const { rawQuery, getAddIntervalQuery, parseFilters } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + }); + const { levelOneQuery, levelQuery, sumQuery, params } = getFunnelQuery(steps, window); + + function getFunnelQuery( + steps: { type: string; value: string }[], + window: number, + ): { + levelOneQuery: string; + levelQuery: string; + sumQuery: string; + params: string[]; + } { + return steps.reduce( + (pv, cv, i) => { + const levelNumber = i + 1; + const startSum = i > 0 ? 'union ' : ''; + const isURL = cv.type === 'path'; + const column = isURL ? 'url_path' : 'event_name'; + + let operator = '='; + let paramValue = cv.value; + + if (cv.value.startsWith('*') || cv.value.endsWith('*')) { + operator = 'like'; + paramValue = cv.value.replace(/^\*|\*$/g, '%'); + } + + if (levelNumber === 1) { + pv.levelOneQuery = ` + WITH level1 AS ( + select distinct website_event.session_id, website_event.created_at + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and ${column} ${operator} {{${i}}} + ${filterQuery} + )`; + } else { + pv.levelQuery += ` + , level${levelNumber} AS ( + select distinct we.session_id, we.created_at + from level${i} l + join website_event we + on l.session_id = we.session_id + where we.website_id = {{websiteId::uuid}} + and we.created_at between l.created_at and ${getAddIntervalQuery( + `l.created_at `, + `${window} minute`, + )} + and we.${column} ${operator} {{${i}}} + and we.created_at <= {{endDate}} + )`; + } + + pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; + pv.params.push(paramValue); + + return pv; + }, + { + levelOneQuery: '', + levelQuery: '', + sumQuery: '', + params: [], + }, + ); + } + + return rawQuery( + ` + ${levelOneQuery} + ${levelQuery} + ${sumQuery} + ORDER BY level; + `, + { + ...params, + ...queryParams, + }, + ).then(formatResults(steps)); +} + +async function clickhouseQuery( + websiteId: string, + parameters: FunnelParameters, + filters: QueryFilters, +): Promise< + { + value: string; + visitors: number; + dropoff: number; + }[] +> { + const { startDate, endDate, window, steps } = parameters; + const { rawQuery, parseFilters } = clickhouse; + const { levelOneQuery, levelQuery, sumQuery, stepFilterQuery, params } = getFunnelQuery( + steps, + window, + ); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + }); + + function getFunnelQuery( + steps: { type: string; value: string }[], + window: number, + ): { + levelOneQuery: string; + levelQuery: string; + sumQuery: string; + stepFilterQuery: string; + params: Record<string, string>; + } { + return steps.reduce( + (pv, cv, i) => { + const levelNumber = i + 1; + const startSum = i > 0 ? 'union all ' : ''; + const startFilter = i > 0 ? 'or' : ''; + const isURL = cv.type === 'path'; + const column = isURL ? 'url_path' : 'event_name'; + + let operator = '='; + let paramValue = cv.value; + + if (cv.value.startsWith('*') || cv.value.endsWith('*')) { + operator = 'like'; + paramValue = cv.value.replace(/^\*|\*$/g, '%'); + } + + if (levelNumber === 1) { + pv.levelOneQuery = `\n + level1 AS ( + select * + from level0 + where ${column} ${operator} {param${i}:String} + )`; + } else { + pv.levelQuery += `\n + , level${levelNumber} AS ( + select distinct y.session_id as session_id, + y.url_path as url_path, + y.referrer_path as referrer_path, + y.event_name, + y.created_at as created_at + from level${i} x + join level0 y + on x.session_id = y.session_id + where y.created_at between x.created_at and x.created_at + interval ${window} minute + and y.${column} ${operator} {param${i}:String} + )`; + } + + pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; + pv.stepFilterQuery += `${startFilter} ${column} ${operator} {param${i}:String} `; + pv.params[`param${i}`] = paramValue; + + return pv; + }, + { + levelOneQuery: '', + levelQuery: '', + sumQuery: '', + stepFilterQuery: '', + params: {}, + }, + ); + } + + return rawQuery( + ` + WITH level0 AS ( + select distinct session_id, url_path, referrer_path, event_name, created_at + from website_event + ${cohortQuery} + where (${stepFilterQuery}) + and website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + ), + ${levelOneQuery} + ${levelQuery} + select * + from ( + ${sumQuery} + ) ORDER BY level; + `, + { + ...params, + ...queryParams, + }, + ).then(formatResults(steps)); +} + +const formatResults = (steps: { type: string; value: string }[]) => (results: unknown) => { + return steps.map((step: { type: string; value: string }, i: number) => { + const visitors = Number(results[i]?.count) || 0; + const previous = Number(results[i - 1]?.count) || 0; + const dropped = previous > 0 ? previous - visitors : 0; + const dropoff = 1 - visitors / previous; + const remaining = visitors / Number(results[0].count); + + return { + ...step, + visitors, + previous, + dropped, + dropoff, + remaining, + }; + }); +}; diff --git a/src/queries/sql/reports/getGoal.ts b/src/queries/sql/reports/getGoal.ts new file mode 100644 index 0000000..7e790ff --- /dev/null +++ b/src/queries/sql/reports/getGoal.ts @@ -0,0 +1,105 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface GoalParameters { + startDate: Date; + endDate: Date; + type: string; + value: string; + operator?: string; + property?: string; +} + +export async function getGoal( + ...args: [websiteId: string, params: GoalParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: GoalParameters, + filters: QueryFilters, +) { + const { startDate, endDate, type, value } = parameters; + const { rawQuery, parseFilters } = prisma; + const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; + const column = type === 'path' ? 'url_path' : 'event_name'; + const { filterQuery, dateQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + value, + startDate, + endDate, + eventType, + }); + + return rawQuery( + ` + select count(distinct website_event.session_id) as num, + ( + select count(distinct website_event.session_id) + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + ${dateQuery} + ${filterQuery} + ) as total + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and ${column} = {{value}} + ${dateQuery} + ${filterQuery} + `, + queryParams, + ).then(results => results?.[0]); +} + +async function clickhouseQuery( + websiteId: string, + parameters: GoalParameters, + filters: QueryFilters, +) { + const { startDate, endDate, type, value } = parameters; + const { rawQuery, parseFilters } = clickhouse; + const eventType = type === 'path' ? EVENT_TYPE.pageView : EVENT_TYPE.customEvent; + const column = type === 'path' ? 'url_path' : 'event_name'; + const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + value, + startDate, + endDate, + eventType, + }); + + return rawQuery( + ` + select count(distinct session_id) as num, + ( + select count(distinct session_id) + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + ${dateQuery} + ${filterQuery} + ) as total + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and ${column} = {value:String} + ${dateQuery} + ${filterQuery} + `, + queryParams, + ).then(results => results?.[0]); +} diff --git a/src/queries/sql/reports/getJourney.ts b/src/queries/sql/reports/getJourney.ts new file mode 100644 index 0000000..283e0fa --- /dev/null +++ b/src/queries/sql/reports/getJourney.ts @@ -0,0 +1,275 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface JourneyParameters { + startDate: Date; + endDate: Date; + steps: number; + startStep?: string; + endStep?: string; +} + +export interface JourneyResult { + e1: string; + e2: string; + e3: string; + e4: string; + e5: string; + e6: string; + e7: string; + count: number; +} + +export async function getJourney( + ...args: [websiteId: string, parameters: JourneyParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: JourneyParameters, + filters: QueryFilters, +): Promise<JourneyResult[]> { + const { startDate, endDate, steps, startStep, endStep } = parameters; + const { rawQuery, parseFilters } = prisma; + const { sequenceQuery, startStepQuery, endStepQuery, params } = getJourneyQuery( + steps, + startStep, + endStep, + ); + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + }); + + function getJourneyQuery( + steps: number, + startStep?: string, + endStep?: string, + ): { + sequenceQuery: string; + startStepQuery: string; + endStepQuery: string; + params: Record<string, string>; + } { + const params = {}; + let sequenceQuery = ''; + let startStepQuery = ''; + let endStepQuery = ''; + + // create sequence query + let selectQuery = ''; + let maxQuery = ''; + let groupByQuery = ''; + + for (let i = 1; i <= steps; i++) { + const endQuery = i < steps ? ',' : ''; + selectQuery += `s.e${i},`; + maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN "event" ELSE NULL END) AS e${i}${endQuery}`; + groupByQuery += `s.e${i}${endQuery} `; + } + + sequenceQuery = `\nsequences as ( + select ${selectQuery} + count(*) count + FROM ( + select visit_id, + ${maxQuery} + FROM events + group by visit_id) s + group by ${groupByQuery}) + `; + + // create start Step params query + if (startStep) { + startStepQuery = `and e1 = {{startStep}}`; + params.startStep = startStep; + } + + // create end Step params query + if (endStep) { + for (let i = 1; i < steps; i++) { + const startQuery = i === 1 ? 'and (' : '\nor '; + endStepQuery += `${startQuery}(e${i} = {{endStep}} and e${i + 1} is null) `; + } + endStepQuery += `\nor (e${steps} = {{endStep}}))`; + + params.endStep = endStep; + } + + return { + sequenceQuery, + startStepQuery, + endStepQuery, + params, + }; + } + + return rawQuery( + ` + WITH events AS ( + select distinct + website_event.visit_id, + website_event.referrer_path, + coalesce(nullIf(website_event.event_name, ''), website_event.url_path) event, + row_number() OVER (PARTITION BY visit_id ORDER BY website_event.created_at) AS event_number + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery}), + ${sequenceQuery} + select * + from sequences + where 1 = 1 + ${startStepQuery} + ${endStepQuery} + order by count desc + limit 100 + `, + { + ...params, + ...queryParams, + }, + ).then(parseResult); +} + +async function clickhouseQuery( + websiteId: string, + parameters: JourneyParameters, + filters: QueryFilters, +): Promise<JourneyResult[]> { + const { startDate, endDate, steps, startStep, endStep } = parameters; + const { rawQuery, parseFilters } = clickhouse; + const { sequenceQuery, startStepQuery, endStepQuery, params } = getJourneyQuery( + steps, + startStep, + endStep, + ); + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + }); + + function getJourneyQuery( + steps: number, + startStep?: string, + endStep?: string, + ): { + sequenceQuery: string; + startStepQuery: string; + endStepQuery: string; + params: Record<string, string>; + } { + const params = {}; + let sequenceQuery = ''; + let startStepQuery = ''; + let endStepQuery = ''; + + // create sequence query + let selectQuery = ''; + let maxQuery = ''; + let groupByQuery = ''; + + for (let i = 1; i <= steps; i++) { + const endQuery = i < steps ? ',' : ''; + selectQuery += `s.e${i},`; + maxQuery += `\nmax(CASE WHEN event_number = ${i} THEN "event" ELSE NULL END) AS e${i}${endQuery}`; + groupByQuery += `s.e${i}${endQuery} `; + } + + sequenceQuery = `\nsequences as ( + select ${selectQuery} + count(*) count + FROM ( + select visit_id, + ${maxQuery} + FROM events + group by visit_id) s + group by ${groupByQuery}) + `; + + // create start Step params query + if (startStep) { + startStepQuery = `and e1 = {startStep:String}`; + params.startStep = startStep; + } + + // create end Step params query + if (endStep) { + for (let i = 1; i < steps; i++) { + const startQuery = i === 1 ? 'and (' : '\nor '; + endStepQuery += `${startQuery}(e${i} = {endStep:String} and e${i + 1} is null) `; + } + endStepQuery += `\nor (e${steps} = {endStep:String}))`; + + params.endStep = endStep; + } + + return { + sequenceQuery, + startStepQuery, + endStepQuery, + params, + }; + } + + return rawQuery( + ` + WITH events AS ( + select distinct + visit_id, + coalesce(nullIf(event_name, ''), url_path) "event", + row_number() OVER (PARTITION BY visit_id ORDER BY created_at) AS event_number + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + ${filterQuery} + and created_at between {startDate:DateTime64} and {endDate:DateTime64}), + ${sequenceQuery} + select * + from sequences + where 1 = 1 + ${startStepQuery} + ${endStepQuery} + order by count desc + limit 100 + `, + { + ...params, + ...queryParams, + }, + ).then(parseResult); +} + +function combineSequentialDuplicates(array: any) { + if (array.length === 0) return array; + + const result = [array[0]]; + + for (let i = 1; i < array.length; i++) { + if (array[i] !== array[i - 1]) { + result.push(array[i]); + } + } + + return result; +} + +function parseResult(data: any) { + return data.map(({ e1, e2, e3, e4, e5, e6, e7, count }) => ({ + items: combineSequentialDuplicates([e1, e2, e3, e4, e5, e6, e7]), + count: +Number(count), + })); +} diff --git a/src/queries/sql/reports/getRetention.ts b/src/queries/sql/reports/getRetention.ts new file mode 100644 index 0000000..87b55e0 --- /dev/null +++ b/src/queries/sql/reports/getRetention.ts @@ -0,0 +1,173 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface RetentionParameters { + startDate: Date; + endDate: Date; + timezone?: string; +} + +export interface RetentionResult { + date: string; + day: number; + visitors: number; + returnVisitors: number; + percentage: number; +} + +export async function getRetention( + ...args: [websiteId: string, parameters: RetentionParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: RetentionParameters, + filters: QueryFilters, +): Promise<RetentionResult[]> { + const { startDate, endDate, timezone } = parameters; + const { getDateSQL, getDayDiffQuery, getCastColumnQuery, rawQuery, parseFilters } = prisma; + const unit = 'day'; + + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + timezone, + }); + + return rawQuery( + ` + WITH cohort_items AS ( + select + min(${getDateSQL('website_event.created_at', unit, timezone)}) as cohort_date, + website_event.session_id + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by website_event.session_id + ), + user_activities AS ( + select distinct + website_event.session_id, + ${getDayDiffQuery(getDateSQL('created_at', unit, timezone), 'cohort_items.cohort_date')} as day_number + from website_event + join cohort_items + on website_event.session_id = cohort_items.session_id + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + + ), + cohort_size as ( + select cohort_date, + count(*) as visitors + from cohort_items + group by 1 + order by 1 + ), + cohort_date as ( + select + c.cohort_date, + a.day_number, + count(*) as visitors + from user_activities a + join cohort_items c + on a.session_id = c.session_id + group by 1, 2 + ) + select + c.cohort_date as date, + c.day_number as day, + s.visitors, + c.visitors as "returnVisitors", + ${getCastColumnQuery('c.visitors', 'float')} * 100 / s.visitors as percentage + from cohort_date c + join cohort_size s + on c.cohort_date = s.cohort_date + where c.day_number <= 31 + order by 1, 2`, + queryParams, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: RetentionParameters, + filters: QueryFilters, +): Promise<RetentionResult[]> { + const { startDate, endDate, timezone } = parameters; + const { getDateSQL, rawQuery, parseFilters } = clickhouse; + const unit = 'day'; + + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + timezone, + }); + + return rawQuery( + ` + WITH cohort_items AS ( + select + min(${getDateSQL('created_at', unit, timezone)}) as cohort_date, + session_id + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by session_id + ), + user_activities AS ( + select distinct + website_event.session_id, + toInt32((${getDateSQL('created_at', unit, timezone)} - cohort_items.cohort_date) / 86400) as day_number + from website_event + join cohort_items + on website_event.session_id = cohort_items.session_id + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ), + cohort_size as ( + select cohort_date, + count(*) as visitors + from cohort_items + group by 1 + order by 1 + ), + cohort_date as ( + select + c.cohort_date, + a.day_number, + count(*) as visitors + from user_activities a + join cohort_items c + on a.session_id = c.session_id + group by 1, 2 + ) + select + c.cohort_date as date, + c.day_number as day, + s.visitors as visitors, + c.visitors returnVisitors, + c.visitors * 100 / s.visitors as percentage + from cohort_date c + join cohort_size s + on c.cohort_date = s.cohort_date + where c.day_number <= 31 + order by 1, 2`, + queryParams, + ); +} diff --git a/src/queries/sql/reports/getRevenue.ts b/src/queries/sql/reports/getRevenue.ts new file mode 100644 index 0000000..fa25078 --- /dev/null +++ b/src/queries/sql/reports/getRevenue.ts @@ -0,0 +1,217 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface RevenuParameters { + startDate: Date; + endDate: Date; + unit: string; + timezone: string; + currency: string; +} + +export interface RevenueResult { + chart: { x: string; t: string; y: number }[]; + country: { name: string; value: number }[]; + total: { sum: number; count: number; average: number; unique_count: number }; +} + +export async function getRevenue( + ...args: [websiteId: string, parameters: RevenuParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: RevenuParameters, + filters: QueryFilters, +): Promise<RevenueResult> { + const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters; + const { getDateSQL, rawQuery, parseFilters } = prisma; + const { queryParams, filterQuery, cohortQuery, joinSessionQuery } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + currency, + }); + + const joinQuery = filterQuery + ? `join website_event + on website_event.website_id = revenue.website_id + and website_event.session_id = revenue.session_id + and website_event.event_id = revenue.event_id + and website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}}` + : ''; + + const chart = await rawQuery( + ` + select + revenue.event_name x, + ${getDateSQL('revenue.created_at', unit, timezone)} t, + sum(revenue.revenue) y + from revenue + ${joinQuery} + ${cohortQuery} + ${joinSessionQuery} + where revenue.website_id = {{websiteId::uuid}} + and revenue.created_at between {{startDate}} and {{endDate}} + and revenue.currency = upper({{currency}}) + ${filterQuery} + group by x, t + order by t + `, + queryParams, + ); + + const country = await rawQuery( + ` + select + session.country as name, + sum(revenue) value + from revenue + ${joinQuery} + join session + on session.website_id = revenue.website_id + and session.session_id = revenue.session_id + ${cohortQuery} + where revenue.website_id = {{websiteId::uuid}} + and revenue.created_at between {{startDate}} and {{endDate}} + and revenue.currency = upper({{currency}}) + ${filterQuery} + group by session.country + `, + queryParams, + ); + + const total = await rawQuery( + ` + select + sum(revenue.revenue) as sum, + count(distinct revenue.event_id) as count, + count(distinct revenue.session_id) as unique_count + from revenue + ${joinQuery} + ${cohortQuery} + ${joinSessionQuery} + where revenue.website_id = {{websiteId::uuid}} + and revenue.created_at between {{startDate}} and {{endDate}} + and revenue.currency = upper({{currency}}) + ${filterQuery} + `, + queryParams, + ).then(result => result?.[0]); + + total.average = total.count > 0 ? Number(total.sum) / Number(total.count) : 0; + + return { chart, country, total }; +} + +async function clickhouseQuery( + websiteId: string, + parameters: RevenuParameters, + filters: QueryFilters, +): Promise<RevenueResult> { + const { startDate, endDate, unit = 'day', timezone = 'utc', currency } = parameters; + const { getDateSQL, rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + currency, + }); + + const joinQuery = filterQuery + ? `join website_event + on website_event.website_id = website_revenue.website_id + and website_event.session_id = website_revenue.session_id + and website_event.event_id = website_revenue.event_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64}` + : ''; + + const chart = await rawQuery< + { + x: string; + t: string; + y: number; + }[] + >( + ` + select + website_revenue.event_name x, + ${getDateSQL('website_revenue.created_at', unit, timezone)} t, + sum(website_revenue.revenue) y + from website_revenue + ${joinQuery} + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and website_revenue.currency = upper({currency:String}) + ${filterQuery} + group by x, t + order by t + `, + queryParams, + ); + + const country = await rawQuery< + { + name: string; + value: number; + }[] + >( + ` + select + website_event.country as name, + sum(website_revenue.revenue) as value + from website_revenue + join website_event + on website_event.website_id = website_revenue.website_id + and website_event.session_id = website_revenue.session_id + and website_event.event_id = website_revenue.event_id + and website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and website_revenue.currency = upper({currency:String}) + ${filterQuery} + group by website_event.country + order by value desc + `, + queryParams, + ); + + const total = await rawQuery<{ + sum: number; + count: number; + unique_count: number; + }>( + ` + select + sum(website_revenue.revenue) as sum, + uniqExact(website_revenue.event_id) as count, + uniqExact(website_revenue.session_id) as unique_count + from website_revenue + ${joinQuery} + ${cohortQuery} + where website_revenue.website_id = {websiteId:UUID} + and website_revenue.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and website_revenue.currency = upper({currency:String}) + ${filterQuery} + `, + queryParams, + ).then(result => result?.[0]); + + total.average = total.count > 0 ? total.sum / total.count : 0; + + return { chart, country, total }; +} diff --git a/src/queries/sql/reports/getUTM.ts b/src/queries/sql/reports/getUTM.ts new file mode 100644 index 0000000..4d43eb4 --- /dev/null +++ b/src/queries/sql/reports/getUTM.ts @@ -0,0 +1,84 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_TYPE } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +export interface UTMParameters { + column: string; + startDate: Date; + endDate: Date; +} + +export async function getUTM( + ...args: [websiteId: string, parameters: UTMParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: UTMParameters, + filters: QueryFilters, +) { + const { column, startDate, endDate } = parameters; + const { parseFilters, rawQuery } = prisma; + + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + eventType: EVENT_TYPE.pageView, + }); + + return rawQuery( + ` + select website_event.${column} utm, count(*) as views + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and coalesce(website_event.${column}, '') != '' + ${filterQuery} + group by 1 + order by 2 desc + `, + queryParams, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: UTMParameters, + filters: QueryFilters, +) { + const { column, startDate, endDate } = parameters; + const { parseFilters, rawQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + startDate, + endDate, + eventType: EVENT_TYPE.pageView, + }); + + return rawQuery( + ` + select ${column} utm, count(*) as views + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and ${column} != '' + ${filterQuery} + group by 1 + order by 2 desc + `, + queryParams, + ); +} diff --git a/src/queries/sql/sessions/createSession.ts b/src/queries/sql/sessions/createSession.ts new file mode 100644 index 0000000..8d07a55 --- /dev/null +++ b/src/queries/sql/sessions/createSession.ts @@ -0,0 +1,44 @@ +import type { Prisma } from '@/generated/prisma/client'; +import prisma from '@/lib/prisma'; + +const FUNCTION_NAME = 'createSession'; + +export async function createSession(data: Prisma.SessionCreateInput) { + const { rawQuery } = prisma; + + await rawQuery( + ` + insert into session ( + session_id, + website_id, + browser, + os, + device, + screen, + language, + country, + region, + city, + distinct_id, + created_at + ) + values ( + {{id}}, + {{websiteId}}, + {{browser}}, + {{os}}, + {{device}}, + {{screen}}, + {{language}}, + {{country}}, + {{region}}, + {{city}}, + {{distinctId}}, + {{createdAt}} + ) + on conflict (session_id) do nothing + `, + data, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionActivity.ts b/src/queries/sql/sessions/getSessionActivity.ts new file mode 100644 index 0000000..af31fca --- /dev/null +++ b/src/queries/sql/sessions/getSessionActivity.ts @@ -0,0 +1,78 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionActivity'; + +export async function getSessionActivity( + ...args: [websiteId: string, sessionId: string, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, sessionId: string, filters: QueryFilters) { + const { rawQuery } = prisma; + const { startDate, endDate } = filters; + + return rawQuery( + ` + select + created_at as "createdAt", + url_path as "urlPath", + url_query as "urlQuery", + referrer_domain as "referrerDomain", + event_id as "eventId", + event_type as "eventType", + event_name as "eventName", + visit_id as "visitId", + event_id IN (select website_event_id + from event_data + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}}) AS "hasData" + from website_event + where website_id = {{websiteId::uuid}} + and session_id = {{sessionId::uuid}} + and created_at between {{startDate}} and {{endDate}} + order by created_at desc + limit 500 + `, + { websiteId, sessionId, startDate, endDate }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery(websiteId: string, sessionId: string, filters: QueryFilters) { + const { rawQuery } = clickhouse; + const { startDate, endDate } = filters; + + return rawQuery( + ` + select + created_at as createdAt, + url_path as urlPath, + url_query as urlQuery, + referrer_domain as referrerDomain, + event_id as eventId, + event_type as eventType, + event_name as eventName, + visit_id as visitId, + event_id IN (select event_id + from event_data + where website_id = {websiteId:UUID} + and session_id = {sessionId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64}) AS hasData + from website_event + where website_id = {websiteId:UUID} + and session_id = {sessionId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + order by created_at desc + limit 500 + `, + { websiteId, sessionId, startDate, endDate }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionData.ts b/src/queries/sql/sessions/getSessionData.ts new file mode 100644 index 0000000..8f1e493 --- /dev/null +++ b/src/queries/sql/sessions/getSessionData.ts @@ -0,0 +1,60 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; + +const FUNCTION_NAME = 'getSessionData'; + +export async function getSessionData(...args: [websiteId: string, sessionId: string]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, sessionId: string) { + const { rawQuery } = prisma; + + return rawQuery( + ` + select + website_id as "websiteId", + session_id as "sessionId", + data_key as "dataKey", + data_type as "dataType", + replace(string_value, '.0000', '') as "stringValue", + number_value as "numberValue", + date_value as "dateValue", + created_at as "createdAt" + from session_data + where website_id = {{websiteId::uuid}} + and session_id = {{sessionId::uuid}} + order by data_key asc + `, + { websiteId, sessionId }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery(websiteId: string, sessionId: string) { + const { rawQuery } = clickhouse; + + return rawQuery( + ` + select + website_id as websiteId, + session_id as sessionId, + data_key as dataKey, + data_type as dataType, + replace(string_value, '.0000', '') as stringValue, + number_value as numberValue, + date_value as dateValue, + created_at as createdAt + from session_data final + where website_id = {websiteId:UUID} + and session_id = {sessionId:UUID} + order by data_key asc + `, + { websiteId, sessionId }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionDataProperties.ts b/src/queries/sql/sessions/getSessionDataProperties.ts new file mode 100644 index 0000000..9b429f9 --- /dev/null +++ b/src/queries/sql/sessions/getSessionDataProperties.ts @@ -0,0 +1,75 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionDataProperties'; + +export async function getSessionDataProperties( + ...args: [websiteId: string, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { rawQuery, parseFilters } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + data_key as "propertyName", + count(distinct session_data.session_id) as "total" + from website_event + ${cohortQuery} + ${joinSessionQuery} + join session_data + on session_data.session_id = website_event.session_id + and session_data.website_id = website_event.website_id + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by 1 + order by 2 desc + limit 500 + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<{ propertyName: string; total: number }[]> { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + + return rawQuery( + ` + select + data_key as propertyName, + count(distinct session_data.session_id) as total + from website_event + ${cohortQuery} + join session_data final + on session_data.session_id = website_event.session_id + and session_data.website_id = {websiteId:UUID} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and session_data.data_key != '' + ${filterQuery} + group by 1 + order by 2 desc + limit 500 + `, + queryParams, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionDataValues.ts b/src/queries/sql/sessions/getSessionDataValues.ts new file mode 100644 index 0000000..5790141 --- /dev/null +++ b/src/queries/sql/sessions/getSessionDataValues.ts @@ -0,0 +1,85 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionDataValues'; + +export async function getSessionDataValues( + ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string }] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + filters: QueryFilters & { propertyName?: string }, +) { + const { rawQuery, parseFilters, getDateSQL } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + case + when data_type = 2 then replace(string_value, '.0000', '') + when data_type = 4 then ${getDateSQL('date_value', 'hour')} + else string_value + end as "value", + count(distinct session_data.session_id) as "total" + from website_event + ${cohortQuery} + ${joinSessionQuery} + join session_data + on session_data.session_id = website_event.session_id + and session_data.website_id = website_event.website_id + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and session_data.data_key = {{propertyName}} + ${filterQuery} + group by value + order by 2 desc + limit 100 + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters & { propertyName?: string }, +): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + + return rawQuery( + ` + select + multiIf(data_type = 2, replaceAll(string_value, '.0000', ''), + data_type = 4, toString(date_trunc('hour', date_value)), + string_value) as "value", + uniq(session_data.session_id) as "total" + from website_event + ${cohortQuery} + join session_data final + on session_data.session_id = website_event.session_id + and session_data.website_id = {websiteId:UUID} + where website_event.website_id = {websiteId:UUID} + and website_event.created_at between {startDate:DateTime64} and {endDate:DateTime64} + and session_data.data_key = {propertyName:String} + ${filterQuery} + group by value + order by 2 desc + limit 100 + `, + queryParams, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionExpandedMetrics.ts b/src/queries/sql/sessions/getSessionExpandedMetrics.ts new file mode 100644 index 0000000..85c1293 --- /dev/null +++ b/src/queries/sql/sessions/getSessionExpandedMetrics.ts @@ -0,0 +1,152 @@ +import clickhouse from '@/lib/clickhouse'; +import { FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionExpandedMetrics'; + +export interface SessionExpandedMetricsParameters { + type: string; + limit?: number | string; + offset?: number | string; +} + +export interface SessionExpandedMetricsData { + name: string; + pageviews: number; + visitors: number; + visits: number; + bounces: number; + totaltime: number; +} + +export async function getSessionExpandedMetrics( + ...args: [websiteId: string, parameters: SessionExpandedMetricsParameters, filters: QueryFilters] +): Promise<SessionExpandedMetricsData[]> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: SessionExpandedMetricsParameters, + filters: QueryFilters, +): Promise<SessionExpandedMetricsData[]> { + const { type, limit = 500, offset = 0 } = parameters; + let column = FILTER_COLUMNS[type] || type; + const { parseFilters, rawQuery, getTimestampDiffSQL } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( + { + ...filters, + websiteId, + }, + { + joinSession: SESSION_COLUMNS.includes(type), + }, + ); + const includeCountry = column === 'city' || column === 'region'; + + if (type === 'language') { + column = `lower(left(${type}, 2))`; + } + + return rawQuery( + ` + select + name, + ${includeCountry ? 'country,' : ''} + sum(t.c) as "pageviews", + count(distinct t.session_id) as "visitors", + count(distinct t.visit_id) as "visits", + sum(case when t.c = 1 then 1 else 0 end) as "bounces", + sum(${getTimestampDiffSQL('t.min_time', 't.max_time')}) as "totaltime" + from ( + select + ${column} name, + ${includeCountry ? 'country,' : ''} + website_event.session_id, + website_event.visit_id, + count(*) as "c", + min(website_event.created_at) as "min_time", + max(website_event.created_at) as "max_time" + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type != 2 + ${filterQuery} + group by name, website_event.session_id, website_event.visit_id + ${includeCountry ? ', country' : ''} + ) as t + group by name + ${includeCountry ? ', country' : ''} + order by visitors desc, visits desc + limit ${limit} + offset ${offset} + `, + { ...queryParams, ...parameters }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: SessionExpandedMetricsParameters, + filters: QueryFilters, +): Promise<SessionExpandedMetricsData[]> { + const { type, limit = 500, offset = 0 } = parameters; + let column = FILTER_COLUMNS[type] || type; + const { parseFilters, rawQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + const includeCountry = column === 'city' || column === 'region'; + + if (type === 'language') { + column = `lower(left(${type}, 2))`; + } + + return rawQuery( + ` + select + name, + ${includeCountry ? 'country,' : ''} + sum(t.c) as "pageviews", + uniq(t.session_id) as "visitors", + uniq(t.visit_id) as "visits", + sum(if(t.c = 1, 1, 0)) as "bounces", + sum(max_time-min_time) as "totaltime" + from ( + select + ${column} name, + ${includeCountry ? 'country,' : ''} + session_id, + visit_id, + count(*) c, + min(created_at) min_time, + max(created_at) max_time + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + and name != '' + ${filterQuery} + group by name, session_id, visit_id + ${includeCountry ? ', country' : ''} + ) as t + group by name + ${includeCountry ? ', country' : ''} + order by visitors desc, visits desc + limit ${limit} + offset ${offset} + `, + { ...queryParams, ...parameters }, + FUNCTION_NAME, + ); +} diff --git a/src/queries/sql/sessions/getSessionMetrics.ts b/src/queries/sql/sessions/getSessionMetrics.ts new file mode 100644 index 0000000..c519bdd --- /dev/null +++ b/src/queries/sql/sessions/getSessionMetrics.ts @@ -0,0 +1,130 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_COLUMNS, FILTER_COLUMNS, SESSION_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionMetrics'; + +export interface SessionMetricsParameters { + type: string; + limit?: number | string; + offset?: number | string; +} + +export async function getSessionMetrics( + ...args: [websiteId: string, parameters: SessionMetricsParameters, filters: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + parameters: SessionMetricsParameters, + filters: QueryFilters, +) { + const { type, limit = 500, offset = 0 } = parameters; + let column = FILTER_COLUMNS[type] || type; + const { parseFilters, rawQuery } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters( + { + ...filters, + websiteId, + }, + { + joinSession: SESSION_COLUMNS.includes(type), + }, + ); + const includeCountry = column === 'city' || column === 'region'; + + if (type === 'language') { + column = `lower(left(${type}, 2))`; + } + + return rawQuery( + ` + select + ${column} x, + count(distinct website_event.session_id) y + ${includeCountry ? ', country' : ''} + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type != 2 + ${filterQuery} + group by 1 + ${includeCountry ? ', 3' : ''} + order by 2 desc + limit ${limit} + offset ${offset} + `, + { ...queryParams, ...parameters }, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + parameters: SessionMetricsParameters, + filters: QueryFilters, +): Promise<{ x: string; y: number }[]> { + const { type, limit = 500, offset = 0 } = parameters; + let column = FILTER_COLUMNS[type] || type; + const { parseFilters, rawQuery } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + const includeCountry = column === 'city' || column === 'region'; + + if (type === 'language') { + column = `lower(left(${type}, 2))`; + } + + let sql = ''; + + if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) { + sql = ` + select + ${column} x, + count(distinct session_id) y + ${includeCountry ? ', country' : ''} + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${filterQuery} + group by x + ${includeCountry ? ', country' : ''} + order by y desc + limit ${limit} + offset ${offset} + `; + } else { + sql = ` + select + ${column} x, + uniq(session_id) y + ${includeCountry ? ', country' : ''} + from website_event_stats_hourly as website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${filterQuery} + group by x + ${includeCountry ? ', country' : ''} + order by y desc + limit ${limit} + offset ${offset} + `; + } + + return rawQuery(sql, { ...queryParams, ...parameters }, FUNCTION_NAME); +} diff --git a/src/queries/sql/sessions/getSessionStats.ts b/src/queries/sql/sessions/getSessionStats.ts new file mode 100644 index 0000000..fd45772 --- /dev/null +++ b/src/queries/sql/sessions/getSessionStats.ts @@ -0,0 +1,98 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getSessionStats'; + +export async function getSessionStats(...args: [websiteId: string, filters: QueryFilters]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { timezone = 'utc', unit = 'day' } = filters; + const { getDateSQL, parseFilters, rawQuery } = prisma; + const { filterQuery, joinSessionQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + ${getDateSQL('website_event.created_at', unit, timezone)} x, + count(distinct website_event.session_id) y + from website_event + ${cohortQuery} + ${joinSessionQuery} + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + and website_event.event_type != 2 + ${filterQuery} + group by 1 + order by 1 + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<{ x: string; y: number }[]> { + const { timezone = 'UTC', unit = 'day' } = filters; + const { parseFilters, rawQuery, getDateSQL } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + let sql = ''; + + if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item)) || unit === 'minute') { + sql = ` + select + g.t as x, + g.y as y + from ( + select + ${getDateSQL('website_event.created_at', unit, timezone)} as t, + count(distinct session_id) as y + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${filterQuery} + group by t + ) as g + order by t + `; + } else { + sql = ` + select + g.t as x, + g.y as y + from ( + select + ${getDateSQL('website_event.created_at', unit, timezone)} as t, + uniq(session_id) as y + from website_event_stats_hourly as website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and event_type != 2 + ${filterQuery} + group by t + ) as g + order by t + `; + } + + return rawQuery(sql, queryParams, FUNCTION_NAME); +} diff --git a/src/queries/sql/sessions/getWebsiteSession.ts b/src/queries/sql/sessions/getWebsiteSession.ts new file mode 100644 index 0000000..3c16087 --- /dev/null +++ b/src/queries/sql/sessions/getWebsiteSession.ts @@ -0,0 +1,113 @@ +import clickhouse from '@/lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; + +const FUNCTION_NAME = 'getWebsiteSession'; + +export async function getWebsiteSession(...args: [websiteId: string, sessionId: string]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, sessionId: string) { + const { rawQuery, getTimestampDiffSQL } = prisma; + + return rawQuery( + ` + select id, + distinct_id as "distinctId", + website_id as "websiteId", + browser, + os, + device, + screen, + language, + country, + region, + city, + min(min_time) as "firstAt", + max(max_time) as "lastAt", + count(distinct visit_id) as visits, + sum(views) as views, + sum(events) as events, + sum(${getTimestampDiffSQL('min_time', 'max_time')}) as "totaltime" + from (select + session.session_id as id, + session.distinct_id, + website_event.visit_id, + session.website_id, + session.browser, + session.os, + session.device, + session.screen, + session.language, + session.country, + session.region, + session.city, + min(website_event.created_at) as min_time, + max(website_event.created_at) as max_time, + sum(case when website_event.event_type = 1 then 1 else 0 end) as views, + sum(case when website_event.event_type = 2 then 1 else 0 end) as events + from session + join website_event on website_event.session_id = session.session_id + where session.website_id = {{websiteId::uuid}} + and session.session_id = {{sessionId::uuid}} + group by session.session_id, session.distinct_id, visit_id, session.website_id, session.browser, session.os, session.device, session.screen, session.language, session.country, session.region, session.city) t + group by id, distinct_id, website_id, browser, os, device, screen, language, country, region, city; + `, + { websiteId, sessionId }, + FUNCTION_NAME, + ).then(result => result?.[0]); +} + +async function clickhouseQuery(websiteId: string, sessionId: string) { + const { rawQuery, getDateStringSQL } = clickhouse; + + return rawQuery( + ` + select id, + websiteId, + distinctId, + browser, + os, + device, + screen, + language, + country, + region, + city, + ${getDateStringSQL('min(min_time)')} as firstAt, + ${getDateStringSQL('max(max_time)')} as lastAt, + uniq(visit_id) visits, + sum(views) as views, + sum(events) as events, + sum(max_time-min_time) as totaltime + from (select + session_id as id, + distinct_id as distinctId, + visit_id, + website_id as websiteId, + browser, + os, + device, + screen, + language, + country, + region, + city, + min(min_time) as min_time, + max(max_time) as max_time, + sum(views) as views, + length(groupArrayArray(event_name)) as events + from website_event_stats_hourly + where website_id = {websiteId:UUID} + and session_id = {sessionId:UUID} + group by session_id, distinct_id, visit_id, website_id, browser, os, device, screen, language, country, region, city) t + group by id, websiteId, distinctId, browser, os, device, screen, language, country, region, city; + `, + { websiteId, sessionId }, + FUNCTION_NAME, + ).then(result => result?.[0]); +} diff --git a/src/queries/sql/sessions/getWebsiteSessionStats.ts b/src/queries/sql/sessions/getWebsiteSessionStats.ts new file mode 100644 index 0000000..a12e6c6 --- /dev/null +++ b/src/queries/sql/sessions/getWebsiteSessionStats.ts @@ -0,0 +1,97 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getWebsiteSessionStats'; + +export interface WebsiteSessionStatsData { + pageviews: number; + visitors: number; + visits: number; + countries: number; + events: number; +} + +export async function getWebsiteSessionStats( + ...args: [websiteId: string, filters: QueryFilters] +): Promise<WebsiteSessionStatsData[]> { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + filters: QueryFilters, +): Promise<WebsiteSessionStatsData[]> { + const { parseFilters, rawQuery } = prisma; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + return rawQuery( + ` + select + count(*) as "pageviews", + count(distinct website_event.session_id) as "visitors", + count(distinct website_event.visit_id) as "visits", + count(distinct session.country) as "countries", + sum(case when website_event.event_type = 2 then 1 else 0 end) as "events" + from website_event + ${cohortQuery} + join session on website_event.session_id = session.session_id + and website_event.website_id = session.website_id + where website_event.website_id = {{websiteId::uuid}} + and website_event.created_at between {{startDate}} and {{endDate}} + ${filterQuery} + `, + queryParams, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters, +): Promise<WebsiteSessionStatsData[]> { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, cohortQuery, queryParams } = parseFilters({ ...filters, websiteId }); + + let sql = ''; + + if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) { + sql = ` + select + sumIf(1, event_type = 1) as "pageviews", + uniq(session_id) as "visitors", + uniq(visit_id) as "visits", + uniq(country) as "countries", + sum(length(event_name)) as "events" + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + `; + } else { + sql = ` + select + sum(views) as "pageviews", + uniq(session_id) as "visitors", + uniq(visit_id) as "visits", + uniq(country) as "countries", + sum(length(event_name)) as "events" + from website_event_stats_hourly website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + `; + } + + return rawQuery(sql, queryParams, FUNCTION_NAME); +} diff --git a/src/queries/sql/sessions/getWebsiteSessions.ts b/src/queries/sql/sessions/getWebsiteSessions.ts new file mode 100644 index 0000000..df640d6 --- /dev/null +++ b/src/queries/sql/sessions/getWebsiteSessions.ts @@ -0,0 +1,156 @@ +import clickhouse from '@/lib/clickhouse'; +import { EVENT_COLUMNS } from '@/lib/constants'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import prisma from '@/lib/prisma'; +import type { QueryFilters } from '@/lib/types'; + +const FUNCTION_NAME = 'getWebsiteSessions'; + +export async function getWebsiteSessions(...args: [websiteId: string, filters: QueryFilters]) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { pagedRawQuery, parseFilters } = prisma; + const { search } = filters; + const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + search: search ? `%${search}%` : undefined, + }); + + const searchQuery = search + ? `and (distinct_id ilike {{search}} + or city ilike {{search}} + or browser ilike {{search}} + or os ilike {{search}} + or device ilike {{search}})` + : ''; + + return pagedRawQuery( + ` + select + session.session_id as "id", + session.website_id as "websiteId", + website_event.hostname, + session.browser, + session.os, + session.device, + session.screen, + session.language, + session.country, + session.region, + session.city, + min(website_event.created_at) as "firstAt", + max(website_event.created_at) as "lastAt", + count(distinct website_event.visit_id) as "visits", + sum(case when website_event.event_type = 1 then 1 else 0 end) as "views", + max(website_event.created_at) as "createdAt" + from website_event + ${cohortQuery} + join session on session.session_id = website_event.session_id + and session.website_id = website_event.website_id + where website_event.website_id = {{websiteId::uuid}} + ${dateQuery} + ${filterQuery} + ${searchQuery} + group by session.session_id, + session.website_id, + website_event.hostname, + session.browser, + session.os, + session.device, + session.screen, + session.language, + session.country, + session.region, + session.city + order by max(website_event.created_at) desc + `, + queryParams, + filters, + FUNCTION_NAME, + ); +} + +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { + const { pagedRawQuery, parseFilters, getDateStringSQL } = clickhouse; + const { search } = filters; + const { filterQuery, dateQuery, cohortQuery, queryParams } = parseFilters({ + ...filters, + websiteId, + }); + + const searchQuery = search + ? `and ((positionCaseInsensitive(distinct_id, {search:String}) > 0) + or (positionCaseInsensitive(city, {search:String}) > 0) + or (positionCaseInsensitive(browser, {search:String}) > 0) + or (positionCaseInsensitive(os, {search:String}) > 0) + or (positionCaseInsensitive(device, {search:String}) > 0))` + : ''; + + let sql = ''; + + if (EVENT_COLUMNS.some(item => Object.keys(filters).includes(item))) { + sql = ` + select + session_id as id, + website_id as websiteId, + hostname, + browser, + os, + device, + screen, + language, + country, + region, + city, + ${getDateStringSQL('min(created_at)')} as firstAt, + ${getDateStringSQL('max(created_at)')} as lastAt, + uniq(visit_id) as visits, + sumIf(1, event_type = 1) as views, + lastAt as createdAt + from website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + ${dateQuery} + ${filterQuery} + ${searchQuery} + group by session_id, website_id, hostname, browser, os, device, screen, language, country, region, city + order by lastAt desc + `; + } else { + sql = ` + select + session_id as id, + website_id as websiteId, + arrayFirst(x -> 1, hostname) hostname, + browser, + os, + device, + screen, + language, + country, + region, + city, + ${getDateStringSQL('min(min_time)')} as firstAt, + ${getDateStringSQL('max(max_time)')} as lastAt, + uniq(visit_id) as visits, + sumIf(views, event_type = 1) as views, + lastAt as createdAt + from website_event_stats_hourly as website_event + ${cohortQuery} + where website_id = {websiteId:UUID} + ${dateQuery} + ${filterQuery} + ${searchQuery} + group by session_id, website_id, hostname, browser, os, device, screen, language, country, region, city + order by lastAt desc + `; + } + + return pagedRawQuery(sql, queryParams, filters, FUNCTION_NAME); +} diff --git a/src/queries/sql/sessions/saveSessionData.ts b/src/queries/sql/sessions/saveSessionData.ts new file mode 100644 index 0000000..7409317 --- /dev/null +++ b/src/queries/sql/sessions/saveSessionData.ts @@ -0,0 +1,112 @@ +import clickhouse from '@/lib/clickhouse'; +import { DATA_TYPE } from '@/lib/constants'; +import { uuid } from '@/lib/crypto'; +import { flattenJSON, getStringValue } from '@/lib/data'; +import { CLICKHOUSE, PRISMA, runQuery } from '@/lib/db'; +import kafka from '@/lib/kafka'; +import prisma from '@/lib/prisma'; +import type { DynamicData } from '@/lib/types'; + +export interface SaveSessionDataArgs { + websiteId: string; + sessionId: string; + sessionData: DynamicData; + distinctId?: string; + createdAt?: Date; +} + +export async function saveSessionData(data: SaveSessionDataArgs) { + return runQuery({ + [PRISMA]: () => relationalQuery(data), + [CLICKHOUSE]: () => clickhouseQuery(data), + }); +} + +export async function relationalQuery({ + websiteId, + sessionId, + sessionData, + distinctId, + createdAt, +}: SaveSessionDataArgs) { + const { client } = prisma; + + const jsonKeys = flattenJSON(sessionData); + + const flattenedData = jsonKeys.map(a => ({ + id: uuid(), + websiteId, + sessionId, + dataKey: a.key, + stringValue: getStringValue(a.value, a.dataType), + numberValue: a.dataType === DATA_TYPE.number ? a.value : null, + dateValue: a.dataType === DATA_TYPE.date ? new Date(a.value) : null, + dataType: a.dataType, + distinctId, + createdAt, + })); + + const existing = await client.sessionData.findMany({ + where: { + sessionId, + }, + select: { + id: true, + sessionId: true, + dataKey: true, + }, + }); + + for (const data of flattenedData) { + const { sessionId, dataKey, ...props } = data; + const record = existing.find(e => e.sessionId === sessionId && e.dataKey === dataKey); + + if (record) { + await client.sessionData.update({ + where: { + id: record.id, + }, + data: { + ...props, + }, + }); + } else { + await client.sessionData.create({ + data, + }); + } + } +} + +async function clickhouseQuery({ + websiteId, + sessionId, + sessionData, + distinctId, + createdAt, +}: SaveSessionDataArgs) { + const { insert, getUTCString } = clickhouse; + const { sendMessage } = kafka; + + const jsonKeys = flattenJSON(sessionData); + + const messages = jsonKeys.map(({ key, value, dataType }) => { + return { + website_id: websiteId, + session_id: sessionId, + data_key: key, + data_type: dataType, + string_value: getStringValue(value, dataType), + number_value: dataType === DATA_TYPE.number ? value : null, + date_value: dataType === DATA_TYPE.date ? getUTCString(value) : null, + distinct_id: distinctId, + created_at: getUTCString(createdAt), + }; + }); + + if (kafka.enabled) { + await sendMessage('session_data', messages); + } else { + await insert('session_data', messages); + } +} diff --git a/src/store/app.ts b/src/store/app.ts new file mode 100644 index 0000000..bb54e56 --- /dev/null +++ b/src/store/app.ts @@ -0,0 +1,50 @@ +import { create } from 'zustand'; +import { + DATE_RANGE_CONFIG, + DEFAULT_DATE_RANGE_VALUE, + DEFAULT_LOCALE, + DEFAULT_THEME, + LOCALE_CONFIG, + THEME_CONFIG, + TIMEZONE_CONFIG, +} from '@/lib/constants'; +import { getTimezone } from '@/lib/date'; +import { getItem } from '@/lib/storage'; + +const initialState = { + locale: getItem(LOCALE_CONFIG) || process.env.defaultLocale || DEFAULT_LOCALE, + theme: getItem(THEME_CONFIG) || DEFAULT_THEME, + timezone: getItem(TIMEZONE_CONFIG) || getTimezone(), + dateRangeValue: getItem(DATE_RANGE_CONFIG) || DEFAULT_DATE_RANGE_VALUE, + shareToken: null, + user: null, + config: null, +}; + +const store = create(() => ({ ...initialState })); + +export function setTimezone(timezone: string) { + store.setState({ timezone }); +} + +export function setLocale(locale: string) { + store.setState({ locale }); +} + +export function setShareToken(shareToken: string) { + store.setState({ shareToken }); +} + +export function setUser(user: object) { + store.setState({ user }); +} + +export function setConfig(config: object) { + store.setState({ config }); +} + +export function setDateRangeValue(dateRangeValue: string) { + store.setState({ dateRangeValue }); +} + +export const useApp = store; diff --git a/src/store/cache.ts b/src/store/cache.ts new file mode 100644 index 0000000..8ac9384 --- /dev/null +++ b/src/store/cache.ts @@ -0,0 +1,9 @@ +import { create } from 'zustand'; + +const store = create(() => ({})); + +export function setValue(key: string, value: any) { + store.setState({ [key]: value }); +} + +export const useCache = store; diff --git a/src/store/dashboard.ts b/src/store/dashboard.ts new file mode 100644 index 0000000..93f59ed --- /dev/null +++ b/src/store/dashboard.ts @@ -0,0 +1,22 @@ +import { create } from 'zustand'; +import { DASHBOARD_CONFIG, DEFAULT_WEBSITE_LIMIT } from '@/lib/constants'; +import { getItem, setItem } from '@/lib/storage'; + +export const initialState = { + showCharts: true, + limit: DEFAULT_WEBSITE_LIMIT, + websiteOrder: [], + websiteActive: [], + editing: false, + isEdited: false, +}; + +const store = create(() => ({ ...initialState, ...getItem(DASHBOARD_CONFIG) })); + +export function saveDashboard(settings) { + store.setState(settings); + + setItem(DASHBOARD_CONFIG, store.getState()); +} + +export const useDashboard = store; diff --git a/src/store/version.ts b/src/store/version.ts new file mode 100644 index 0000000..95367af --- /dev/null +++ b/src/store/version.ts @@ -0,0 +1,55 @@ +import { produce } from 'immer'; +import semver from 'semver'; +import { create } from 'zustand'; +import { CURRENT_VERSION, UPDATES_URL, VERSION_CHECK } from '@/lib/constants'; +import { getItem } from '@/lib/storage'; + +const initialState = { + current: CURRENT_VERSION, + latest: null, + hasUpdate: false, + checked: false, + releaseUrl: null, +}; + +const store = create(() => ({ ...initialState })); + +export async function checkVersion() { + const { current } = store.getState(); + + const data = await fetch(`${UPDATES_URL}?v=${current}`, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + }).then(res => { + if (res.ok) { + return res.json(); + } + + return null; + }); + + if (!data) { + return; + } + + store.setState( + produce(state => { + const { latest, url } = data; + const lastCheck = getItem(VERSION_CHECK); + + const hasUpdate = !!(latest && lastCheck?.version !== latest && semver.gt(latest, current)); + + state.current = current; + state.latest = latest; + state.hasUpdate = hasUpdate; + state.checked = true; + state.releaseUrl = url; + + return state; + }), + ); +} + +export const useVersion = store; diff --git a/src/store/websites.ts b/src/store/websites.ts new file mode 100644 index 0000000..4ddcab0 --- /dev/null +++ b/src/store/websites.ts @@ -0,0 +1,35 @@ +import { produce } from 'immer'; +import { create } from 'zustand'; +import type { DateRange } from '@/lib/types'; + +const store = create(() => ({})); + +export function setWebsiteDateRange(websiteId: string, dateRange: DateRange) { + store.setState( + produce(state => { + if (!state[websiteId]) { + state[websiteId] = {}; + } + + state[websiteId].dateRange = { ...dateRange, modified: Date.now() }; + + return state; + }), + ); +} + +export function setWebsiteDateCompare(websiteId: string, dateCompare: string) { + store.setState( + produce(state => { + if (!state[websiteId]) { + state[websiteId] = {}; + } + + state[websiteId].dateCompare = dateCompare; + + return state; + }), + ); +} + +export const useWebsites = store; diff --git a/src/styles/global.css b/src/styles/global.css new file mode 100644 index 0000000..e9fca9f --- /dev/null +++ b/src/styles/global.css @@ -0,0 +1,43 @@ +html, +body { + font-family: var(--font-family), sans-serif; + color: var(--font-color); + font-size: var(--font-size); + background-color: var(--base-color-2); + width: 100%; + min-height: 100vh; +} + +html[style*="padding-right"] { + padding-right: 0 !important; +} + +a, +a:active, +a:hover { + color: var(--font-color); + text-decoration: none; +} + +::-webkit-scrollbar { + width: 15px; + background: transparent; +} + +::-webkit-scrollbar-track { + border: 7px solid rgba(0, 0, 0, 0); + background-color: var(--base-color-4); + background-clip: padding-box; +} + +::-webkit-scrollbar-thumb { + border: 7px solid rgba(0, 0, 0, 0); + background-color: var(--base-color-9); + border-radius: var(--border-radius-full); + background-clip: padding-box; +} + +::-webkit-scrollbar-thumb:hover { + border: 4px solid rgba(0, 0, 0, 0); + background-clip: padding-box; +} diff --git a/src/styles/variables.css b/src/styles/variables.css new file mode 100644 index 0000000..f7ebb02 --- /dev/null +++ b/src/styles/variables.css @@ -0,0 +1,4 @@ +html body { + --primary-color: #147af3; + --primary-font-color: var(--light-color); +} diff --git a/src/tracker/index.d.ts b/src/tracker/index.d.ts new file mode 100644 index 0000000..32fbee9 --- /dev/null +++ b/src/tracker/index.d.ts @@ -0,0 +1,153 @@ +export type TrackedProperties = { + /** + * Hostname of server + * + * @description extracted from `window.location.hostname` + * @example 'analytics.umami.is' + */ + hostname: string; + + /** + * Browser language + * + * @description extracted from `window.navigator.language` + * @example 'en-US', 'fr-FR' + */ + language: string; + + /** + * Page referrer + * + * @description extracted from `window.navigator.language` + * @example 'https://analytics.umami.is/docs/getting-started' + */ + referrer: string; + + /** + * Screen dimensions + * + * @description extracted from `window.screen.width` and `window.screen.height` + * @example '1920x1080', '2560x1440' + */ + screen: string; + + /** + * Page title + * + * @description extracted from `document.querySelector('head > title')` + * @example 'umami' + */ + title: string; + + /** + * Page url + * + * @description built from `${window.location.pathname}${window.location.search}` + * @example 'docs/getting-started' + */ + url: string; + + /** + * Website ID (required) + * + * @example 'b59e9c65-ae32-47f1-8400-119fcf4861c4' + */ + website: string; +}; + +export type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] }; + +/** + * + * Event Data can work with any JSON data. There are a few rules in place to maintain performance. + * - Numbers have a max precision of 4. + * - Strings have a max length of 500. + * - Arrays are converted to a String, with the same max length of 500. + * - Objects have a max of 50 properties. Arrays are considered 1 property. + */ +export interface EventData { + [key: string]: number | string | EventData | number[] | string[] | EventData[]; +} + +export type EventProperties = { + /** + * NOTE: event names will be truncated past 50 characters + */ + name: string; + data?: EventData; +} & WithRequired<TrackedProperties, 'website'>; +export type PageViewProperties = WithRequired<TrackedProperties, 'website'>; +export type CustomEventFunction = ( + props: PageViewProperties, +) => EventProperties | PageViewProperties; + +export type UmamiTracker = { + track: { + /** + * Track a page view + * + * @example ``` + * umami.track(); + * ``` + */ + (): Promise<string>; + + /** + * Track an event with a given name + * + * NOTE: event names will be truncated past 50 characters + * + * @example ``` + * umami.track('signup-button'); + * ``` + */ + (eventName: string): Promise<string>; + + /** + * Tracks an event with dynamic data. + * + * NOTE: event names will be truncated past 50 characters + * + * When tracking events, the default properties are included in the payload. This is equivalent to running: + * + * ```js + * umami.track(props => ({ + * ...props, + * name: 'signup-button', + * data: { + * name: 'newsletter', + * id: 123 + * } + * })); + * ``` + * + * @example ``` + * umami.track('signup-button', { name: 'newsletter', id: 123 }); + * ``` + */ + (eventName: string, obj: EventData): Promise<string>; + + /** + * Tracks a page view with custom properties + * + * @example ``` + * umami.track({ website: 'e676c9b4-11e4-4ef1-a4d7-87001773e9f2', url: '/home', title: 'Home page' }); + * ``` + */ + (properties: PageViewProperties): Promise<string>; + + /** + * Tracks an event with fully customizable dynamic data + * If you don't specify any `name` and/or `data`, it will be treated as a page view + * + * @example ``` + * umami.track((props) => ({ ...props, url: path })); + * ``` + */ + (eventFunction: CustomEventFunction): Promise<string>; + }; +}; + +export interface Window { + umami: UmamiTracker; +} diff --git a/src/tracker/index.js b/src/tracker/index.js new file mode 100644 index 0000000..ad3648a --- /dev/null +++ b/src/tracker/index.js @@ -0,0 +1,240 @@ +(window => { + const { + screen: { width, height }, + navigator: { language, doNotTrack: ndnt, msDoNotTrack: msdnt }, + location, + document, + history, + top, + doNotTrack, + } = window; + const { currentScript, referrer } = document; + if (!currentScript) return; + + const { hostname, href, origin } = location; + const localStorage = href.startsWith('data:') ? undefined : window.localStorage; + + const _data = 'data-'; + const _false = 'false'; + const _true = 'true'; + const attr = currentScript.getAttribute.bind(currentScript); + + const website = attr(`${_data}website-id`); + const hostUrl = attr(`${_data}host-url`); + const beforeSend = attr(`${_data}before-send`); + const tag = attr(`${_data}tag`) || undefined; + const autoTrack = attr(`${_data}auto-track`) !== _false; + const dnt = attr(`${_data}do-not-track`) === _true; + const excludeSearch = attr(`${_data}exclude-search`) === _true; + const excludeHash = attr(`${_data}exclude-hash`) === _true; + const domain = attr(`${_data}domains`) || ''; + const credentials = attr(`${_data}fetch-credentials`) || 'omit'; + + const domains = domain.split(',').map(n => n.trim()); + const host = + hostUrl || '__COLLECT_API_HOST__' || currentScript.src.split('/').slice(0, -1).join('/'); + const endpoint = `${host.replace(/\/$/, '')}__COLLECT_API_ENDPOINT__`; + const screen = `${width}x${height}`; + const eventRegex = /data-umami-event-([\w-_]+)/; + const eventNameAttribute = `${_data}umami-event`; + const delayDuration = 300; + + /* Helper functions */ + + const normalize = raw => { + if (!raw) return raw; + try { + const u = new URL(raw, location.href); + if (excludeSearch) u.search = ''; + if (excludeHash) u.hash = ''; + return u.toString(); + } catch { + return raw; + } + }; + + const getPayload = () => ({ + website, + screen, + language, + title: document.title, + hostname, + url: currentUrl, + referrer: currentRef, + tag, + id: identity ? identity : undefined, + }); + + const hasDoNotTrack = () => { + const dnt = doNotTrack || ndnt || msdnt; + return dnt === 1 || dnt === '1' || dnt === 'yes'; + }; + + /* Event handlers */ + + const handlePush = (_state, _title, url) => { + if (!url) return; + + currentRef = currentUrl; + currentUrl = normalize(new URL(url, location.href).toString()); + + if (currentUrl !== currentRef) { + setTimeout(track, delayDuration); + } + }; + + const handlePathChanges = () => { + const hook = (_this, method, callback) => { + const orig = _this[method]; + return (...args) => { + callback.apply(null, args); + return orig.apply(_this, args); + }; + }; + + history.pushState = hook(history, 'pushState', handlePush); + history.replaceState = hook(history, 'replaceState', handlePush); + }; + + const handleClicks = () => { + const trackElement = async el => { + const eventName = el.getAttribute(eventNameAttribute); + if (eventName) { + const eventData = {}; + + el.getAttributeNames().forEach(name => { + const match = name.match(eventRegex); + if (match) eventData[match[1]] = el.getAttribute(name); + }); + + return track(eventName, eventData); + } + }; + const onClick = async e => { + const el = e.target; + const parentElement = el.closest('a,button'); + if (!parentElement) return trackElement(el); + + const { href, target } = parentElement; + if (!parentElement.getAttribute(eventNameAttribute)) return; + + if (parentElement.tagName === 'BUTTON') { + return trackElement(parentElement); + } + if (parentElement.tagName === 'A' && href) { + const external = + target === '_blank' || + e.ctrlKey || + e.shiftKey || + e.metaKey || + (e.button && e.button === 1); + if (!external) e.preventDefault(); + return trackElement(parentElement).then(() => { + if (!external) { + (target === '_top' ? top.location : location).href = href; + } + }); + } + }; + document.addEventListener('click', onClick, true); + }; + + /* Tracking functions */ + + const trackingDisabled = () => + disabled || + !website || + localStorage?.getItem('umami.disabled') || + (domain && !domains.includes(hostname)) || + (dnt && hasDoNotTrack()); + + const send = async (payload, type = 'event') => { + if (trackingDisabled()) return; + + const callback = window[beforeSend]; + + if (typeof callback === 'function') { + payload = await Promise.resolve(callback(type, payload)); + } + + if (!payload) return; + + try { + const res = await fetch(endpoint, { + keepalive: true, + method: 'POST', + body: JSON.stringify({ type, payload }), + headers: { + 'Content-Type': 'application/json', + ...(typeof cache !== 'undefined' && { 'x-umami-cache': cache }), + }, + credentials, + }); + + const data = await res.json(); + if (data) { + disabled = !!data.disabled; + cache = data.cache; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_e) { + /* no-op */ + } + }; + + const init = () => { + if (!initialized) { + initialized = true; + track(); + handlePathChanges(); + handleClicks(); + } + }; + + const track = (name, data) => { + if (typeof name === 'string') return send({ ...getPayload(), name, data }); + if (typeof name === 'object') return send({ ...name }); + if (typeof name === 'function') return send(name(getPayload())); + return send(getPayload()); + }; + + const identify = (id, data) => { + if (typeof id === 'string') { + identity = id; + } + + cache = ''; + return send( + { + ...getPayload(), + data: typeof id === 'object' ? id : data, + }, + 'identify', + ); + }; + + /* Start */ + + if (!window.umami) { + window.umami = { + track, + identify, + }; + } + + let currentUrl = normalize(href); + let currentRef = normalize(referrer.startsWith(origin) ? '' : referrer); + + let initialized = false; + let disabled = false; + let cache; + let identity; + + if (autoTrack && !trackingDisabled()) { + if (document.readyState === 'complete') { + init(); + } else { + document.addEventListener('readystatechange', init, true); + } + } +})(window); |