mirror of
https://github.com/umami-software/umami.git
synced 2026-02-11 16:17:13 +01:00
Add board component selector with live preview
Allows users to select and inject analytics components into board cells. Includes component registry, renderer, selector modal with category menu, config fields for MetricsTable, and live preview. Also scopes website select to team websites when editing a team board. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
28246b8c52
commit
87bde9da1f
10 changed files with 498 additions and 113 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -47,4 +47,3 @@ yarn-error.log*
|
||||||
*.env.*
|
*.env.*
|
||||||
|
|
||||||
*.dev.yml
|
*.dev.yml
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
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 { t, labels, messages } = useMessages();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { touch } = useModified();
|
|
||||||
const { teamId } = useNavigation();
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
toast(t(messages.saved));
|
|
||||||
touch('boards');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DialogTrigger>
|
|
||||||
<Button data-test="button-board-add" variant="primary">
|
|
||||||
<Icon>
|
|
||||||
<Plus />
|
|
||||||
</Icon>
|
|
||||||
<Text>{t(labels.addBoard)}</Text>
|
|
||||||
</Button>
|
|
||||||
<Modal>
|
|
||||||
<Dialog title={t(labels.addBoard)} style={{ width: 400 }}>
|
|
||||||
{({ close }) => <BoardAddForm teamId={teamId} onSave={handleSave} onClose={close} />}
|
|
||||||
</Dialog>
|
|
||||||
</Modal>
|
|
||||||
</DialogTrigger>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { Button, Form, FormField, FormSubmitButton, Row, Text, TextField } from '@umami/react-zen';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useMessages, useUpdateQuery } from '@/components/hooks';
|
|
||||||
import { WebsiteSelect } from '@/components/input/WebsiteSelect';
|
|
||||||
|
|
||||||
export function BoardAddForm({
|
|
||||||
teamId,
|
|
||||||
onSave,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
teamId?: string;
|
|
||||||
onSave?: () => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}) {
|
|
||||||
const { t, labels } = useMessages();
|
|
||||||
const { mutateAsync, error, isPending } = useUpdateQuery('/boards', { teamId });
|
|
||||||
const [websiteId, setWebsiteId] = useState<string>();
|
|
||||||
|
|
||||||
const handleSubmit = async (data: any) => {
|
|
||||||
await mutateAsync(
|
|
||||||
{ type: 'board', ...data, parameters: { websiteId } },
|
|
||||||
{
|
|
||||||
onSuccess: async () => {
|
|
||||||
onSave?.();
|
|
||||||
onClose?.();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form onSubmit={handleSubmit} error={error?.message}>
|
|
||||||
<FormField label={t(labels.name)} name="name" rules={{ required: t(labels.required) }}>
|
|
||||||
<TextField autoComplete="off" />
|
|
||||||
</FormField>
|
|
||||||
<FormField
|
|
||||||
label={t(labels.description)}
|
|
||||||
name="description"
|
|
||||||
rules={{
|
|
||||||
required: t(labels.required),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TextField asTextArea autoComplete="off" />
|
|
||||||
</FormField>
|
|
||||||
<Row alignItems="center" gap="3" paddingTop="3">
|
|
||||||
<Text>{t(labels.website)}</Text>
|
|
||||||
<WebsiteSelect websiteId={websiteId} teamId={teamId} onChange={setWebsiteId} />
|
|
||||||
</Row>
|
|
||||||
<Row justifyContent="flex-end" paddingTop="3" gap="3">
|
|
||||||
{onClose && (
|
|
||||||
<Button isDisabled={isPending} onPress={onClose}>
|
|
||||||
{t(labels.cancel)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<FormSubmitButton data-test="button-submit" isDisabled={false}>
|
|
||||||
{t(labels.save)}
|
|
||||||
</FormSubmitButton>
|
|
||||||
</Row>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,43 @@
|
||||||
import { Box, Button, Column, Icon, Tooltip, TooltipTrigger } from '@umami/react-zen';
|
import {
|
||||||
import type { ReactElement } from 'react';
|
Box,
|
||||||
import { Plus, X } from '@/components/icons';
|
Button,
|
||||||
|
Column,
|
||||||
|
Dialog,
|
||||||
|
Icon,
|
||||||
|
Modal,
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@umami/react-zen';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useBoard } from '@/components/hooks';
|
||||||
|
import { Pencil, Plus, X } from '@/components/icons';
|
||||||
|
import type { BoardComponentConfig } from '@/lib/types';
|
||||||
|
import { BoardComponentRenderer } from './BoardComponentRenderer';
|
||||||
|
import { BoardComponentSelect } from './BoardComponentSelect';
|
||||||
|
|
||||||
export function BoardColumn({
|
export function BoardColumn({
|
||||||
id,
|
id,
|
||||||
component,
|
component,
|
||||||
editing = false,
|
editing = false,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onSetComponent,
|
||||||
canRemove = true,
|
canRemove = true,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
component?: ReactElement;
|
component?: BoardComponentConfig;
|
||||||
editing?: boolean;
|
editing?: boolean;
|
||||||
onRemove?: (id: string) => void;
|
onRemove?: (id: string) => void;
|
||||||
|
onSetComponent?: (id: string, config: BoardComponentConfig | null) => void;
|
||||||
canRemove?: boolean;
|
canRemove?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const handleAddComponent = () => {};
|
const [showSelect, setShowSelect] = useState(false);
|
||||||
|
const { board } = useBoard();
|
||||||
|
const websiteId = board?.parameters?.websiteId;
|
||||||
|
|
||||||
|
const handleSelect = (config: BoardComponentConfig) => {
|
||||||
|
onSetComponent?.(id, config);
|
||||||
|
setShowSelect(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column
|
<Column
|
||||||
|
|
@ -40,13 +62,52 @@ export function BoardColumn({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{editing && (
|
{component && websiteId ? (
|
||||||
<Button variant="outline" onPress={handleAddComponent}>
|
<>
|
||||||
<Icon>
|
<Box width="100%" height="100%" overflow="auto">
|
||||||
<Plus />
|
<BoardComponentRenderer config={component} websiteId={websiteId} />
|
||||||
</Icon>
|
</Box>
|
||||||
</Button>
|
{editing && (
|
||||||
|
<Box position="absolute" bottom="10px" right="20px" zIndex={100}>
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
<Button variant="quiet" onPress={() => setShowSelect(true)}>
|
||||||
|
<Icon size="sm">
|
||||||
|
<Pencil />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
<Tooltip>Change component</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
editing && (
|
||||||
|
<Button variant="outline" onPress={() => setShowSelect(true)}>
|
||||||
|
<Icon>
|
||||||
|
<Plus />
|
||||||
|
</Icon>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
<Modal isOpen={showSelect} onOpenChange={setShowSelect}>
|
||||||
|
<Dialog
|
||||||
|
title="Add component"
|
||||||
|
style={{
|
||||||
|
width: '750px',
|
||||||
|
maxWidth: 'calc(100vw - 40px)',
|
||||||
|
maxHeight: 'calc(100dvh - 40px)',
|
||||||
|
padding: '32px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<BoardComponentSelect
|
||||||
|
websiteId={websiteId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onClose={() => setShowSelect(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
</Column>
|
</Column>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx
Normal file
25
src/app/(main)/boards/[boardId]/BoardComponentRenderer.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Column, Text } from '@umami/react-zen';
|
||||||
|
import type { BoardComponentConfig } from '@/lib/types';
|
||||||
|
import { getComponentDefinition } from '../boardComponentRegistry';
|
||||||
|
|
||||||
|
export function BoardComponentRenderer({
|
||||||
|
config,
|
||||||
|
websiteId,
|
||||||
|
}: {
|
||||||
|
config: BoardComponentConfig;
|
||||||
|
websiteId: string;
|
||||||
|
}) {
|
||||||
|
const definition = getComponentDefinition(config.type);
|
||||||
|
|
||||||
|
if (!definition) {
|
||||||
|
return (
|
||||||
|
<Column alignItems="center" justifyContent="center" width="100%" height="100%">
|
||||||
|
<Text color="muted">Unknown component: {config.type}</Text>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = definition.component;
|
||||||
|
|
||||||
|
return <Component websiteId={websiteId} {...config.props} />;
|
||||||
|
}
|
||||||
175
src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx
Normal file
175
src/app/(main)/boards/[boardId]/BoardComponentSelect.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Column,
|
||||||
|
Focusable,
|
||||||
|
Heading,
|
||||||
|
ListItem,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Text,
|
||||||
|
} from '@umami/react-zen';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { BoardComponentConfig } from '@/lib/types';
|
||||||
|
import {
|
||||||
|
CATEGORIES,
|
||||||
|
type ComponentDefinition,
|
||||||
|
type ConfigField,
|
||||||
|
getComponentsByCategory,
|
||||||
|
} from '../boardComponentRegistry';
|
||||||
|
import { BoardComponentRenderer } from './BoardComponentRenderer';
|
||||||
|
|
||||||
|
export function BoardComponentSelect({
|
||||||
|
websiteId,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
websiteId: string;
|
||||||
|
onSelect: (config: BoardComponentConfig) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const [selectedDef, setSelectedDef] = useState<ComponentDefinition | null>(null);
|
||||||
|
const [configValues, setConfigValues] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
const handleSelectComponent = (def: ComponentDefinition) => {
|
||||||
|
setSelectedDef(def);
|
||||||
|
const defaults: Record<string, any> = {};
|
||||||
|
if (def.configFields) {
|
||||||
|
for (const field of def.configFields) {
|
||||||
|
defaults[field.name] = field.defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (def.defaultProps) {
|
||||||
|
Object.assign(defaults, def.defaultProps);
|
||||||
|
}
|
||||||
|
setConfigValues(defaults);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfigChange = (name: string, value: any) => {
|
||||||
|
setConfigValues(prev => ({ ...prev, [name]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!selectedDef) return;
|
||||||
|
|
||||||
|
const props: Record<string, any> = {};
|
||||||
|
if (selectedDef.defaultProps) {
|
||||||
|
Object.assign(props, selectedDef.defaultProps);
|
||||||
|
}
|
||||||
|
Object.assign(props, configValues);
|
||||||
|
|
||||||
|
if (props.limit) {
|
||||||
|
props.limit = Number(props.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: BoardComponentConfig = { type: selectedDef.type };
|
||||||
|
if (Object.keys(props).length > 0) {
|
||||||
|
config.props = props;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewConfig: BoardComponentConfig | null = selectedDef
|
||||||
|
? {
|
||||||
|
type: selectedDef.type,
|
||||||
|
props: { ...selectedDef.defaultProps, ...configValues },
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column gap="4">
|
||||||
|
<Row gap="4" style={{ height: 500 }}>
|
||||||
|
<Column gap="1" style={{ width: 200, flexShrink: 0, overflowY: 'auto' }}>
|
||||||
|
{CATEGORIES.map(cat => {
|
||||||
|
const components = getComponentsByCategory(cat.key);
|
||||||
|
return (
|
||||||
|
<Column key={cat.key} gap="1" marginBottom="2">
|
||||||
|
<Heading size="md">{cat.name}</Heading>
|
||||||
|
{components.map(def => (
|
||||||
|
<Focusable key={def.type}>
|
||||||
|
<Row
|
||||||
|
alignItems="center"
|
||||||
|
paddingX="3"
|
||||||
|
paddingY="2"
|
||||||
|
borderRadius
|
||||||
|
backgroundColor={
|
||||||
|
selectedDef?.type === def.type ? 'surface-sunken' : undefined
|
||||||
|
}
|
||||||
|
hover={{ backgroundColor: 'surface-sunken' }}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => handleSelectComponent(def)}
|
||||||
|
>
|
||||||
|
<Column>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
weight={selectedDef?.type === def.type ? 'bold' : undefined}
|
||||||
|
>
|
||||||
|
{def.name}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" color="muted">
|
||||||
|
{def.description}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</Focusable>
|
||||||
|
))}
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Column>
|
||||||
|
<Column gap="3" flexGrow={1} style={{ minWidth: 0 }}>
|
||||||
|
{selectedDef?.configFields && selectedDef.configFields.length > 0 && (
|
||||||
|
<Row gap="3" alignItems="center" flexWrap="wrap">
|
||||||
|
{selectedDef.configFields.map((field: ConfigField) => (
|
||||||
|
<Row key={field.name} gap="2" alignItems="center">
|
||||||
|
<Text size="sm" color="muted">
|
||||||
|
{field.label}
|
||||||
|
</Text>
|
||||||
|
{field.type === 'select' && (
|
||||||
|
<Select
|
||||||
|
value={String(configValues[field.name] ?? field.defaultValue ?? '')}
|
||||||
|
onChange={(value: string) => handleConfigChange(field.name, value)}
|
||||||
|
>
|
||||||
|
{field.options?.map(opt => (
|
||||||
|
<ListItem key={opt.value} id={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</Row>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
<Box
|
||||||
|
flexGrow={1}
|
||||||
|
border
|
||||||
|
borderRadius
|
||||||
|
overflow="auto"
|
||||||
|
position="relative"
|
||||||
|
style={{ minHeight: 0 }}
|
||||||
|
>
|
||||||
|
{previewConfig && websiteId ? (
|
||||||
|
<BoardComponentRenderer config={previewConfig} websiteId={websiteId} />
|
||||||
|
) : (
|
||||||
|
<Column alignItems="center" justifyContent="center" height="100%">
|
||||||
|
<Text color="muted">
|
||||||
|
{websiteId ? 'Select a component to preview' : 'Select a website first'}
|
||||||
|
</Text>
|
||||||
|
</Column>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
<Row justifyContent="flex-end" gap="2" paddingTop="4" border="top">
|
||||||
|
<Button variant="quiet" onPress={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onPress={handleAdd} isDisabled={!selectedDef}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ import { WebsiteSelect } from '@/components/input/WebsiteSelect';
|
||||||
export function BoardEditHeader() {
|
export function BoardEditHeader() {
|
||||||
const { board, updateBoard, saveBoard, isPending } = useBoard();
|
const { board, updateBoard, saveBoard, isPending } = useBoard();
|
||||||
const { t, labels } = useMessages();
|
const { t, labels } = useMessages();
|
||||||
const { router, renderUrl } = useNavigation();
|
const { router, renderUrl, teamId } = useNavigation();
|
||||||
const defaultName = t(labels.untitled);
|
const defaultName = t(labels.untitled);
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
const handleNameChange = (value: string) => {
|
||||||
|
|
@ -81,7 +81,11 @@ export function BoardEditHeader() {
|
||||||
</Row>
|
</Row>
|
||||||
<Row alignItems="center" gap="3">
|
<Row alignItems="center" gap="3">
|
||||||
<Text>{t(labels.website)}</Text>
|
<Text>{t(labels.website)}</Text>
|
||||||
<WebsiteSelect websiteId={board?.parameters?.websiteId} onChange={handleWebsiteChange} />
|
<WebsiteSelect
|
||||||
|
websiteId={board?.parameters?.websiteId}
|
||||||
|
teamId={teamId}
|
||||||
|
onChange={handleWebsiteChange}
|
||||||
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
</Column>
|
</Column>
|
||||||
<Column justifyContent="center" alignItems="flex-end">
|
<Column justifyContent="center" alignItems="flex-end">
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { Group, type GroupImperativeHandle, Panel, Separator } from 'react-resiz
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { useBoard } from '@/components/hooks';
|
import { useBoard } from '@/components/hooks';
|
||||||
import { ChevronDown, Minus, Plus } from '@/components/icons';
|
import { ChevronDown, Minus, Plus } from '@/components/icons';
|
||||||
import type { BoardColumn as BoardColumnType } from '@/lib/types';
|
import type { BoardColumn as BoardColumnType, BoardComponentConfig } from '@/lib/types';
|
||||||
import { BoardColumn } from './BoardColumn';
|
import { BoardColumn } from './BoardColumn';
|
||||||
import { MAX_COLUMNS, MIN_COLUMN_WIDTH } from './boardConstants';
|
import { MAX_COLUMNS, MIN_COLUMN_WIDTH } from './boardConstants';
|
||||||
|
|
||||||
|
|
@ -61,6 +61,20 @@ export function BoardRow({
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetComponent = (columnId: string, config: BoardComponentConfig | null) => {
|
||||||
|
updateBoard({
|
||||||
|
parameters: produce(board.parameters, draft => {
|
||||||
|
const row = draft.rows.find(row => row.id === rowId);
|
||||||
|
if (row) {
|
||||||
|
const col = row.columns.find(col => col.id === columnId);
|
||||||
|
if (col) {
|
||||||
|
col.component = config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group groupRef={handleGroupRef} style={{ height: '100%' }}>
|
<Group groupRef={handleGroupRef} style={{ height: '100%' }}>
|
||||||
{columns?.map((column, index) => (
|
{columns?.map((column, index) => (
|
||||||
|
|
@ -70,6 +84,7 @@ export function BoardRow({
|
||||||
{...column}
|
{...column}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
onRemove={handleRemoveColumn}
|
onRemove={handleRemoveColumn}
|
||||||
|
onSetComponent={handleSetComponent}
|
||||||
canRemove={columns?.length > 1}
|
canRemove={columns?.length > 1}
|
||||||
/>
|
/>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
201
src/app/(main)/boards/boardComponentRegistry.tsx
Normal file
201
src/app/(main)/boards/boardComponentRegistry.tsx
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import { Attribution } from '@/app/(main)/websites/[websiteId]/(reports)/attribution/Attribution';
|
||||||
|
import { Breakdown } from '@/app/(main)/websites/[websiteId]/(reports)/breakdown/Breakdown';
|
||||||
|
import { Funnel } from '@/app/(main)/websites/[websiteId]/(reports)/funnels/Funnel';
|
||||||
|
import { Goal } from '@/app/(main)/websites/[websiteId]/(reports)/goals/Goal';
|
||||||
|
import { Journey } from '@/app/(main)/websites/[websiteId]/(reports)/journeys/Journey';
|
||||||
|
import { Retention } from '@/app/(main)/websites/[websiteId]/(reports)/retention/Retention';
|
||||||
|
import { Revenue } from '@/app/(main)/websites/[websiteId]/(reports)/revenue/Revenue';
|
||||||
|
import { UTM } from '@/app/(main)/websites/[websiteId]/(reports)/utm/UTM';
|
||||||
|
import { WebsiteChart } from '@/app/(main)/websites/[websiteId]/WebsiteChart';
|
||||||
|
import { WebsiteMetricsBar } from '@/app/(main)/websites/[websiteId]/WebsiteMetricsBar';
|
||||||
|
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 interface ConfigField {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: 'select' | 'number' | 'text';
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
defaultValue?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentDefinition {
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
component: ComponentType<any>;
|
||||||
|
defaultProps?: Record<string, any>;
|
||||||
|
configFields?: ConfigField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORIES = [
|
||||||
|
{ key: 'overview', name: 'Overview' },
|
||||||
|
{ key: 'tables', name: 'Tables' },
|
||||||
|
{ key: 'visualization', name: 'Visualization' },
|
||||||
|
{ key: 'reports', name: 'Reports' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const METRIC_TYPES = [
|
||||||
|
{ label: 'Pages', value: 'path' },
|
||||||
|
{ label: 'Entry pages', value: 'entry' },
|
||||||
|
{ label: 'Exit pages', value: 'exit' },
|
||||||
|
{ label: 'Referrers', value: 'referrer' },
|
||||||
|
{ label: 'Channels', value: 'channel' },
|
||||||
|
{ label: 'Browsers', value: 'browser' },
|
||||||
|
{ label: 'OS', value: 'os' },
|
||||||
|
{ label: 'Devices', value: 'device' },
|
||||||
|
{ label: 'Countries', value: 'country' },
|
||||||
|
{ label: 'Regions', value: 'region' },
|
||||||
|
{ label: 'Cities', value: 'city' },
|
||||||
|
{ label: 'Languages', value: 'language' },
|
||||||
|
{ label: 'Screens', value: 'screen' },
|
||||||
|
{ label: 'Query parameters', value: 'query' },
|
||||||
|
{ label: 'Page titles', value: 'title' },
|
||||||
|
{ label: 'Hosts', value: 'host' },
|
||||||
|
{ label: 'Events', value: 'event' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LIMIT_OPTIONS = [
|
||||||
|
{ label: '5', value: '5' },
|
||||||
|
{ label: '10', value: '10' },
|
||||||
|
{ label: '20', value: '20' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const componentDefinitions: ComponentDefinition[] = [
|
||||||
|
// Overview
|
||||||
|
{
|
||||||
|
type: 'WebsiteMetricsBar',
|
||||||
|
name: 'Metrics bar',
|
||||||
|
description: 'Key metrics: views, visitors, bounces, time on site',
|
||||||
|
category: 'overview',
|
||||||
|
component: WebsiteMetricsBar,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'WebsiteChart',
|
||||||
|
name: 'Website chart',
|
||||||
|
description: 'Page views and visitors over time',
|
||||||
|
category: 'overview',
|
||||||
|
component: WebsiteChart,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tables
|
||||||
|
{
|
||||||
|
type: 'MetricsTable',
|
||||||
|
name: 'Metrics table',
|
||||||
|
description: 'Table of metrics by dimension',
|
||||||
|
category: 'tables',
|
||||||
|
component: MetricsTable,
|
||||||
|
defaultProps: { type: 'path', limit: 10 },
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
name: 'type',
|
||||||
|
label: 'Metric type',
|
||||||
|
type: 'select',
|
||||||
|
options: METRIC_TYPES,
|
||||||
|
defaultValue: 'path',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'limit',
|
||||||
|
label: 'Rows',
|
||||||
|
type: 'select',
|
||||||
|
options: LIMIT_OPTIONS,
|
||||||
|
defaultValue: '10',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Visualization
|
||||||
|
{
|
||||||
|
type: 'WorldMap',
|
||||||
|
name: 'World map',
|
||||||
|
description: 'Geographic distribution of visitors',
|
||||||
|
category: 'visualization',
|
||||||
|
component: WorldMap,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'WeeklyTraffic',
|
||||||
|
name: 'Weekly traffic',
|
||||||
|
description: 'Traffic heatmap by day and hour',
|
||||||
|
category: 'visualization',
|
||||||
|
component: WeeklyTraffic,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'EventsChart',
|
||||||
|
name: 'Events chart',
|
||||||
|
description: 'Custom events over time',
|
||||||
|
category: 'visualization',
|
||||||
|
component: EventsChart,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
{
|
||||||
|
type: 'Retention',
|
||||||
|
name: 'Retention',
|
||||||
|
description: 'User retention cohort analysis',
|
||||||
|
category: 'reports',
|
||||||
|
component: Retention,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Funnel',
|
||||||
|
name: 'Funnel',
|
||||||
|
description: 'Conversion funnel visualization',
|
||||||
|
category: 'reports',
|
||||||
|
component: Funnel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Goal',
|
||||||
|
name: 'Goal',
|
||||||
|
description: 'Goal tracking and progress',
|
||||||
|
category: 'reports',
|
||||||
|
component: Goal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Journey',
|
||||||
|
name: 'Journey',
|
||||||
|
description: 'User navigation flow',
|
||||||
|
category: 'reports',
|
||||||
|
component: Journey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'UTM',
|
||||||
|
name: 'UTM',
|
||||||
|
description: 'UTM campaign performance',
|
||||||
|
category: 'reports',
|
||||||
|
component: UTM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Revenue',
|
||||||
|
name: 'Revenue',
|
||||||
|
description: 'Revenue analytics and trends',
|
||||||
|
category: 'reports',
|
||||||
|
component: Revenue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Attribution',
|
||||||
|
name: 'Attribution',
|
||||||
|
description: 'Traffic source attribution',
|
||||||
|
category: 'reports',
|
||||||
|
component: Attribution,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Breakdown',
|
||||||
|
name: 'Breakdown',
|
||||||
|
description: 'Multi-dimensional data breakdown',
|
||||||
|
category: 'reports',
|
||||||
|
component: Breakdown,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const definitionMap = new Map(componentDefinitions.map(def => [def.type, def]));
|
||||||
|
|
||||||
|
export function getComponentDefinition(type: string): ComponentDefinition | undefined {
|
||||||
|
return definitionMap.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getComponentsByCategory(category: string): ComponentDefinition[] {
|
||||||
|
return componentDefinitions.filter(def => def.category === category);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { UseQueryOptions } from '@tanstack/react-query';
|
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import type { Board as PrismaBoard } from '@/generated/prisma/client';
|
import type { Board as PrismaBoard } from '@/generated/prisma/client';
|
||||||
import type { DATA_TYPE, OPERATORS, ROLES } from './constants';
|
import type { DATA_TYPE, OPERATORS, ROLES } from './constants';
|
||||||
import type { TIME_UNIT } from './date';
|
import type { TIME_UNIT } from './date';
|
||||||
|
|
@ -145,15 +144,14 @@ export interface ApiError extends Error {
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardComponent {
|
export interface BoardComponentConfig {
|
||||||
id: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
value: string;
|
props?: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoardColumn {
|
export interface BoardColumn {
|
||||||
id: string;
|
id: string;
|
||||||
component?: ReactElement;
|
component?: BoardComponentConfig;
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue