refactor packages

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-19 10:55:15 +01:00
parent ae8482c1e3
commit 2f3c5ddf76
142 changed files with 2234 additions and 5507 deletions

View File

@@ -31,30 +31,20 @@
"email-validator": "^2.0.4",
"embla-carousel-autoplay": "8.0.0-rc22",
"embla-carousel-react": "8.0.0-rc22",
"hamburger-react": "^2.5.0",
"lucide-react": "^0.323.0",
"next": "~14.0.4",
"nuqs": "^1.15.2",
"react": "18.2.0",
"react-animate-height": "^3.2.3",
"react-dom": "18.2.0",
"react-in-viewport": "1.0.0-alpha.30",
"react-responsive": "^9.0.2",
"react-syntax-highlighter": "^15.5.0",
"sharp": "^0.33.2",
"tailwind-merge": "^1.14.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^2.9.1",
"zod": "^3.22.4"
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"@types/bcrypt": "^5.0.0",
"@types/lodash.debounce": "^4.0.9",
"@types/node": "^18.16.0",
"@types/ramda": "^0.29.6",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-syntax-highlighter": "^15.5.9",

View File

@@ -1,9 +0,0 @@
import { toast } from 'sonner';
export function clipboard(value: string | number) {
navigator.clipboard.writeText(value.toString());
toast({
title: 'Copied to clipboard',
description: value.toString(),
});
}

View File

@@ -1,99 +0,0 @@
export const operators = {
is: 'Is',
isNot: 'Is not',
contains: 'Contains',
doesNotContain: 'Not contains',
} as const;
export const chartTypes = {
linear: 'Linear',
bar: 'Bar',
histogram: 'Histogram',
pie: 'Pie',
metric: 'Metric',
area: 'Area',
map: 'Map',
} as const;
export const lineTypes = {
monotone: 'Monotone',
monotoneX: 'Monotone X',
monotoneY: 'Monotone Y',
linear: 'Linear',
natural: 'Natural',
basis: 'Basis',
step: 'Step',
stepBefore: 'Step before',
stepAfter: 'Step after',
basisClosed: 'Basis closed',
basisOpen: 'Basis open',
bumpX: 'Bump X',
bumpY: 'Bump Y',
bump: 'Bump',
linearClosed: 'Linear closed',
} as const;
export const intervals = {
minute: 'minute',
day: 'day',
hour: 'hour',
month: 'month',
} as const;
export const alphabetIds = [
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
] as const;
export const timeRanges = {
'30min': '30min',
'1h': '1h',
today: 'today',
'24h': '24h',
'7d': '7d',
'14d': '14d',
'1m': '1m',
'3m': '3m',
'6m': '6m',
'1y': '1y',
} as const;
export const metrics = {
sum: 'sum',
average: 'average',
min: 'min',
max: 'max',
} as const;
export function isMinuteIntervalEnabledByRange(range: keyof typeof timeRanges) {
return range === '30min' || range === '1h';
}
export function isHourIntervalEnabledByRange(range: keyof typeof timeRanges) {
return (
isMinuteIntervalEnabledByRange(range) ||
range === 'today' ||
range === '24h'
);
}
export function getDefaultIntervalByRange(
range: keyof typeof timeRanges
): keyof typeof intervals {
if (range === '30min' || range === '1h') {
return 'minute';
} else if (range === 'today' || range === '24h') {
return 'hour';
} else if (range === '7d' || range === '14d' || range === '1m') {
return 'day';
}
return 'month';
}

View File

@@ -1,32 +0,0 @@
export function getDaysOldDate(days: number) {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}
export function dateDifferanceInDays(date1: Date, date2: Date) {
const diffTime = Math.abs(date2.getTime() - date1.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
}
export function getLocale() {
if (typeof navigator === 'undefined') {
return 'en-US';
}
return navigator.language ?? 'en-US';
}
export function formatDate(date: Date) {
return new Intl.DateTimeFormat(getLocale()).format(date);
}
export function formatDateTime(date: Date) {
return new Intl.DateTimeFormat(getLocale(), {
day: 'numeric',
month: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
}).format(date);
}

View File

@@ -1,6 +0,0 @@
import type { Profile } from '@mixan/db';
export function getProfileName(profile: Profile | undefined | null) {
if (!profile) return 'No profile';
return [profile.first_name, profile.last_name].filter(Boolean).join(' ');
}

View File

@@ -1,22 +0,0 @@
import { isNumber } from 'mathjs';
export const round = (num: number, decimals = 2) => {
const factor = Math.pow(10, decimals);
return Math.round((num + Number.EPSILON) * factor) / factor;
};
export const average = (arr: (number | null)[]) => {
const filtered = arr.filter(isNumber);
return filtered.reduce((p, c) => p + c, 0) / filtered.length;
};
export const sum = (arr: (number | null)[]): number =>
round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0));
export const min = (arr: (number | null)[]): number =>
Math.min(...arr.filter(isNumber));
export const max = (arr: (number | null)[]): number =>
Math.max(...arr.filter(isNumber));
export const isFloat = (n: number) => n % 1 !== 0;

View File

@@ -1,18 +0,0 @@
import _slugify from 'slugify';
const slugify = (str: string) => {
return _slugify(
str
.replace('å', 'a')
.replace('ä', 'a')
.replace('ö', 'o')
.replace('Å', 'A')
.replace('Ä', 'A')
.replace('Ö', 'O'),
{ lower: true, strict: true, trim: true }
);
};
export function slug(str: string): string {
return slugify(str);
}

View File

@@ -1,17 +0,0 @@
import resolveConfig from 'tailwindcss/resolveConfig';
import tailwinConfig from '../../tailwind.config';
export const resolvedTailwindConfig = resolveConfig(tailwinConfig);
export const theme = resolvedTailwindConfig.theme as Record<string, any>;
export function getChartColor(index: number): string {
const colors = theme?.colors ?? {};
const chartColors: string[] = Object.keys(colors)
.filter((key) => key.startsWith('chart-'))
.map((key) => colors[key])
.filter((item): item is string => typeof item === 'string');
return chartColors[index % chartColors.length]!;
}

View File

@@ -1,6 +0,0 @@
export function truncate(str: string, len: number) {
if (str.length <= len) {
return str;
}
return str.slice(0, len) + '...';
}

View File

@@ -1,75 +0,0 @@
import { z } from 'zod';
import {
chartTypes,
intervals,
lineTypes,
metrics,
operators,
timeRanges,
} from './constants';
export function objectToZodEnums<K extends string>(
obj: Record<K, any>
): [K, ...K[]] {
const [firstKey, ...otherKeys] = Object.keys(obj) as K[];
return [firstKey!, ...otherKeys];
}
export const mapKeys = objectToZodEnums;
export const zChartEvent = z.object({
id: z.string(),
name: z.string(),
displayName: z.string().optional(),
property: z.string().optional(),
segment: z.enum([
'event',
'user',
'user_average',
'one_event_per_user',
'property_sum',
'property_average',
]),
filters: z.array(
z.object({
id: z.string(),
name: z.string(),
operator: z.enum(objectToZodEnums(operators)),
value: z.array(z.string().or(z.number()).or(z.boolean()).or(z.null())),
})
),
});
export const zChartBreakdown = z.object({
id: z.string(),
name: z.string(),
});
export const zChartEvents = z.array(zChartEvent);
export const zChartBreakdowns = z.array(zChartBreakdown);
export const zChartType = z.enum(objectToZodEnums(chartTypes));
export const zLineType = z.enum(objectToZodEnums(lineTypes));
export const zTimeInterval = z.enum(objectToZodEnums(intervals));
export const zMetric = z.enum(objectToZodEnums(metrics));
export const zChartInput = z.object({
name: z.string(),
chartType: zChartType,
lineType: zLineType,
interval: zTimeInterval,
events: zChartEvents,
breakdowns: zChartBreakdowns,
range: z.enum(objectToZodEnums(timeRanges)),
previous: z.boolean(),
formula: z.string().optional(),
metric: zMetric,
unit: z.string().optional(),
previousIndicatorInverted: z.boolean().optional(),
projectId: z.string(),
startDate: z.string().nullish(),
endDate: z.string().nullish(),
});

View File

@@ -15,7 +15,8 @@ function getHostname(url: string | undefined) {
}
export function parseReferrer(url: string | undefined) {
const match = referrers[getHostname(url)];
const hostname = getHostname(url);
const match = referrers[hostname] ?? referrers[hostname.replace('www.', '')];
return {
name: match?.name ?? '',

View File

@@ -55,6 +55,8 @@ COPY packages/db/package.json packages/db/package.json
COPY packages/redis/package.json packages/redis/package.json
COPY packages/queue/package.json packages/queue/package.json
COPY packages/common/package.json packages/common/package.json
COPY packages/constants/package.json packages/constants/package.json
COPY packages/validation/package.json packages/validation/package.json
COPY packages/types/package.json packages/types/package.json
# BUILD
@@ -92,11 +94,15 @@ COPY --from=build /app/packages/db /app/packages/db
COPY --from=build /app/packages/redis /app/packages/redis
COPY --from=build /app/packages/common /app/packages/common
COPY --from=build /app/packages/queue /app/packages/queue
COPY --from=build /app/packages/constants /app/packages/constants
COPY --from=build /app/packages/validation /app/packages/validation
COPY --from=build /app/packages/types /app/packages/types
# Packages node_modules
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
COPY --from=prod /app/packages/redis/node_modules /app/packages/redis/node_modules
COPY --from=prod /app/packages/common/node_modules /app/packages/common/node_modules
COPY --from=prod /app/packages/constants/node_modules /app/packages/constants/node_modules
COPY --from=prod /app/packages/validation/node_modules /app/packages/validation/node_modules
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
RUN pnpm db:codegen

View File

@@ -16,6 +16,8 @@
"@clickhouse/client": "^0.2.9",
"@hookform/resolvers": "^3.3.4",
"@mixan/common": "workspace:^",
"@mixan/constants": "workspace:^",
"@mixan/validation": "workspace:^",
"@mixan/db": "workspace:^",
"@mixan/queue": "workspace:^",
"@mixan/types": "workspace:*",
@@ -90,8 +92,8 @@
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^18.19.15",
"@types/ramda": "^0.29.10",
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"@types/react-syntax-highlighter": "^15.5.11",
"@types/request-ip": "^0.0.41",
"@typescript-eslint/eslint-plugin": "^6.21.0",

View File

@@ -5,7 +5,6 @@ import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layo
import { LazyChart } from '@/components/report/chart/LazyChart';
import { ReportRange } from '@/components/report/ReportRange';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import {
DropdownMenu,
DropdownMenuContent,
@@ -14,14 +13,15 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAppParams } from '@/hooks/useAppParams';
import type { getReportsByDashboardId } from '@/server/services/reports.service';
import type { IChartRange } from '@/types';
import { cn } from '@/utils/cn';
import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants';
import { ChevronRight, MoreHorizontal, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { getDefaultIntervalByRange } from '@mixan/constants';
import type { getReportsByDashboardId } from '@mixan/db';
import type { IChartRange } from '@mixan/validation';
interface ListReportsProps {
reports: Awaited<ReturnType<typeof getReportsByDashboardId>>;
}

View File

@@ -1,9 +1,9 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getDashboardById } from '@/server/services/dashboard.service';
import { getReportsByDashboardId } from '@/server/services/reports.service';
import { notFound } from 'next/navigation';
import { getDashboardById, getReportsByDashboardId } from '@mixan/db';
import { ListReports } from './list-reports';
interface PageProps {

View File

@@ -7,12 +7,13 @@ import { Button } from '@/components/ui/button';
import { ToastAction } from '@/components/ui/toast';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import type { IServiceDashboards } from '@/server/services/dashboard.service';
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceDashboards } from '@mixan/db';
interface ListDashboardsProps {
dashboards: IServiceDashboards;
}

View File

@@ -1,6 +1,7 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getDashboardsByProjectId } from '@/server/services/dashboard.service';
import { getDashboardsByProjectId } from '@mixan/db';
import { HeaderDashboards } from './header-dashboards';
import { ListDashboards } from './list-dashboards';

View File

@@ -1,13 +1,16 @@
'use client';
import type { RouterOutputs } from '@/app/_trpc/client';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
import { useAppParams } from '@/hooks/useAppParams';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import { round } from '@/utils/math';
import { uniq } from 'ramda';
import type { IServiceCreateEventPayload } from '@mixan/db';
@@ -40,7 +43,8 @@ export function EventListItem({
meta,
}: EventListItemProps) {
const params = useAppParams();
const eventQueryFilters = useEventQueryFilters({ shallow: false });
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
const [, setFilter] = useEventQueryFilters({ shallow: false });
const keyValueList = [
{
name: 'Duration',
@@ -50,98 +54,98 @@ export function EventListItem({
name: 'Referrer',
value: referrer,
onClick() {
eventQueryFilters.referrer.set(referrer ?? null);
setFilter('referrer', referrer ?? '');
},
},
{
name: 'Referrer name',
value: referrerName,
onClick() {
eventQueryFilters.referrerName.set(referrerName ?? null);
setFilter('referrer_name', referrerName ?? '');
},
},
{
name: 'Referrer type',
value: referrerType,
onClick() {
eventQueryFilters.referrerType.set(referrerType ?? null);
setFilter('referrer_type', referrerType ?? '');
},
},
{
name: 'Brand',
value: brand,
onClick() {
eventQueryFilters.brand.set(brand ?? null);
setFilter('brand', brand ?? '');
},
},
{
name: 'Model',
value: model,
onClick() {
eventQueryFilters.model.set(model ?? null);
setFilter('model', model ?? '');
},
},
{
name: 'Browser',
value: browser,
onClick() {
eventQueryFilters.browser.set(browser ?? null);
setFilter('browser', browser ?? '');
},
},
{
name: 'Browser version',
value: browserVersion,
onClick() {
eventQueryFilters.browserVersion.set(browserVersion ?? null);
setFilter('browser_version', browserVersion ?? '');
},
},
{
name: 'OS',
value: os,
onClick() {
eventQueryFilters.os.set(os ?? null);
setFilter('os', os ?? '');
},
},
{
name: 'OS cersion',
value: osVersion,
onClick() {
eventQueryFilters.osVersion.set(osVersion ?? null);
setFilter('os_version', osVersion ?? '');
},
},
{
name: 'City',
value: city,
onClick() {
eventQueryFilters.city.set(city ?? null);
setFilter('city', city ?? '');
},
},
{
name: 'Region',
value: region,
onClick() {
eventQueryFilters.region.set(region ?? null);
setFilter('region', region ?? '');
},
},
{
name: 'Country',
value: country,
onClick() {
eventQueryFilters.country.set(country ?? null);
setFilter('country', country ?? '');
},
},
{
name: 'Continent',
value: continent,
onClick() {
eventQueryFilters.continent.set(continent ?? null);
setFilter('continent', continent ?? '');
},
},
{
name: 'Device',
value: device,
onClick() {
eventQueryFilters.device.set(device ?? null);
setFilter('device', device ?? '');
},
},
].filter((item) => typeof item.value === 'string' && item.value);
@@ -156,7 +160,11 @@ export function EventListItem({
return (
<ExpandableListItem
className={cn(meta?.conversion && 'ring-2 ring-primary-500')}
title={name.split('_').join(' ')}
title={
<button onClick={() => setEvents((p) => uniq([...p, name]))}>
{name.split('_').join(' ')}
</button>
}
content={
<>
<KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
@@ -172,7 +180,7 @@ export function EventListItem({
name="Path"
value={path}
onClick={() => {
eventQueryFilters.path.set(path);
setFilter('path', path);
}}
/>
)}
@@ -191,6 +199,13 @@ export function EventListItem({
key={item.name}
name={item.name}
value={item.value}
onClick={() => {
setFilter(
`properties.${item.name}`,
item.value ? String(item.value) : '',
'is'
);
}}
/>
))}
</div>

View File

@@ -5,7 +5,7 @@ import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination } from '@/components/Pagination';
import { Button } from '@/components/ui/button';
import { useCursor } from '@/hooks/useCursor';
import { useEventFilters } from '@/hooks/useEventQueryFilters';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { GanttChartIcon } from 'lucide-react';
import type { IServiceCreateEventPayload } from '@mixan/db';
@@ -18,8 +18,7 @@ interface EventListProps {
}
export function EventList({ data, count }: EventListProps) {
const { cursor, setCursor } = useCursor();
const filters = useEventFilters();
const [filters] = useEventQueryFilters();
return (
<Suspense>
<div className="p-4">

View File

@@ -1,7 +1,10 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { getEventFilters } from '@/hooks/useEventQueryFilters';
import {
eventQueryFiltersParser,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { getEventList, getEventsCount } from '@mixan/db';
@@ -15,27 +18,9 @@ interface PageProps {
organizationId: string;
};
searchParams: {
events?: string;
cursor?: string;
path?: string;
device?: string;
referrer?: string;
referrerName?: string;
referrerType?: string;
utmSource?: string;
utmMedium?: string;
utmCampaign?: string;
utmContent?: string;
utmTerm?: string;
continent?: string;
country?: string;
region?: string;
city?: string;
browser?: string;
browserVersion?: string;
os?: string;
osVersion?: string;
brand?: string;
model?: string;
f?: string;
};
}
@@ -59,53 +44,13 @@ export default async function Page({
cursor: parseQueryAsNumber(searchParams.cursor),
projectId,
take: 50,
filters: getEventFilters({
path: searchParams.path ?? null,
device: searchParams.device ?? null,
referrer: searchParams.referrer ?? null,
referrerName: searchParams.referrerName ?? null,
referrerType: searchParams.referrerType ?? null,
utmSource: searchParams.utmSource ?? null,
utmMedium: searchParams.utmMedium ?? null,
utmCampaign: searchParams.utmCampaign ?? null,
utmContent: searchParams.utmContent ?? null,
utmTerm: searchParams.utmTerm ?? null,
continent: searchParams.continent ?? null,
country: searchParams.country ?? null,
region: searchParams.region ?? null,
city: searchParams.city ?? null,
browser: searchParams.browser ?? null,
browserVersion: searchParams.browserVersion ?? null,
os: searchParams.os ?? null,
osVersion: searchParams.osVersion ?? null,
brand: searchParams.brand ?? null,
model: searchParams.model ?? null,
}),
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
}),
getEventsCount({
projectId,
filters: getEventFilters({
path: searchParams.path ?? null,
device: searchParams.device ?? null,
referrer: searchParams.referrer ?? null,
referrerName: searchParams.referrerName ?? null,
referrerType: searchParams.referrerType ?? null,
utmSource: searchParams.utmSource ?? null,
utmMedium: searchParams.utmMedium ?? null,
utmCampaign: searchParams.utmCampaign ?? null,
utmContent: searchParams.utmContent ?? null,
utmTerm: searchParams.utmTerm ?? null,
continent: searchParams.continent ?? null,
country: searchParams.country ?? null,
region: searchParams.region ?? null,
city: searchParams.city ?? null,
browser: searchParams.browser ?? null,
browserVersion: searchParams.browserVersion ?? null,
os: searchParams.os ?? null,
osVersion: searchParams.osVersion ?? null,
brand: searchParams.brand ?? null,
model: searchParams.model ?? null,
}),
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
}),
getExists(organizationId, projectId),
]);
@@ -116,6 +61,7 @@ export default async function Page({
<OverviewFiltersDrawer
projectId={projectId}
nuqsOptions={nuqsOptions}
enableEventsFilter
/>
<OverviewFiltersButtons
className="p-0 justify-end"

View File

@@ -2,7 +2,6 @@
import { useEffect } from 'react';
import { useAppParams } from '@/hooks/useAppParams';
import type { IServiceDashboards } from '@/server/services/dashboard.service';
import { cn } from '@/utils/cn';
import { useUser } from '@clerk/nextjs';
import {
@@ -21,6 +20,8 @@ import type { LucideProps } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { IServiceDashboards } from '@mixan/db';
function LinkWithIcon({
href,
icon: Icon,

View File

@@ -2,10 +2,11 @@
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import type { IServiceOrganization } from '@/server/services/organization.service';
import { Building } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { IServiceOrganization } from '@mixan/db';
interface LayoutOrganizationSelectorProps {
organizations: IServiceOrganization[];
}

View File

@@ -2,11 +2,12 @@
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import type { getCurrentProjects } from '@/server/services/project.service';
import { usePathname, useRouter } from 'next/navigation';
import type { getProjectsByOrganizationSlug } from '@mixan/db';
interface LayoutProjectSelectorProps {
projects: Awaited<ReturnType<typeof getCurrentProjects>>;
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
}
export default function LayoutProjectSelector({
projects,

View File

@@ -2,13 +2,13 @@
import { useEffect, useState } from 'react';
import { Logo } from '@/components/Logo';
import type { IServiceDashboards } from '@/server/services/dashboard.service';
import type { IServiceOrganization } from '@/server/services/organization.service';
import { cn } from '@/utils/cn';
import { Rotate as Hamburger } from 'hamburger-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { IServiceDashboards, IServiceOrganization } from '@mixan/db';
import LayoutMenu from './layout-menu';
import LayoutOrganizationSelector from './layout-organization-selector';

View File

@@ -1,5 +1,7 @@
import { getDashboardsByOrganization } from '@/server/services/dashboard.service';
import { getCurrentOrganizations } from '@/server/services/organization.service';
import {
getCurrentOrganizations,
getDashboardsByOrganization,
} from '@mixan/db';
import { LayoutSidebar } from './layout-sidebar';

View File

@@ -4,17 +4,18 @@ import { WidgetHead } from '@/components/overview/overview-widget';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Chart } from '@/components/report/chart';
import { Widget, WidgetBody } from '@/components/Widget';
import { useEventFilters } from '@/hooks/useEventQueryFilters';
import type { IChartInput } from '@/types';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import type { IChartInput } from '@mixan/validation';
interface OverviewMetricsProps {
projectId: string;
}
export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const { previous, range, interval, metric, setMetric } = useOverviewOptions();
const filters = useEventFilters();
const [filters] = useEventQueryFilters();
const reports = [
{

View File

@@ -1,4 +1,4 @@
import { getCurrentProjects } from '@/server/services/project.service';
import { getProjectsByOrganizationSlug } from '@mixan/db';
import LayoutProjectSelector from './layout-project-selector';
@@ -13,7 +13,7 @@ export default async function PageLayout({
title,
organizationSlug,
}: PageLayoutProps) {
const projects = await getCurrentProjects(organizationSlug);
const projects = await getProjectsByOrganizationSlug(organizationSlug);
return (
<>

View File

@@ -18,7 +18,7 @@ export default function ListProfileEvents({
projectId,
profileId,
}: ListProfileEvents) {
const pagination = usePagination();
const pagination = usePagination(50);
const [eventFilters, setEventFilters] = useQueryState(
'events',
parseAsJson<string[]>().withDefault([])

View File

@@ -3,13 +3,11 @@ import { ListProperties } from '@/components/events/ListProperties';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { getExists } from '@/server/pageExists';
import {
getProfileById,
getProfilesByExternalId,
} from '@/server/services/profile.service';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import { getProfileById, getProfilesByExternalId } from '@mixan/db';
import ListProfileEvents from './list-profile-events';
interface PageProps {

View File

@@ -1,6 +1,5 @@
'use client';
import { useMemo } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { ListProperties } from '@/components/events/ListProperties';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
@@ -16,24 +15,24 @@ export function ProfileListItem(props: ProfileListItemProps) {
const { id, properties, createdAt } = props;
const params = useAppParams();
const bullets = useMemo(() => {
const bullets: React.ReactNode[] = [
<span>{formatDateTime(createdAt)}</span>,
<Link
href={`/${params.organizationId}/${params.projectId}/profiles/${id}`}
className="text-black font-medium hover:underline"
>
See profile
</Link>,
];
return bullets;
}, [createdAt, id, params]);
const renderContent = () => {
return (
<>
<span>{formatDateTime(createdAt)}</span>
<Link
href={`/${params.organizationId}/${params.projectId}/profiles/${id}`}
className="text-black font-medium hover:underline"
>
See profile
</Link>
</>
);
};
return (
<ExpandableListItem
title={getProfileName(props)}
bullets={bullets}
content={renderContent()}
image={<ProfileAvatar {...props} />}
>
<ListProperties data={properties} className="rounded-none border-none" />

View File

@@ -1,10 +1,10 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { getReportById } from '@/server/services/reports.service';
import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import { getOrganizationBySlug, getReportById } from '@mixan/db';
import ReportEditor from '../report-editor';
interface PageProps {

View File

@@ -1,9 +1,10 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { Pencil } from 'lucide-react';
import { notFound } from 'next/navigation';
import { getOrganizationBySlug } from '@mixan/db';
import ReportEditor from './report-editor';
interface PageProps {

View File

@@ -19,9 +19,10 @@ import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';
import type { IServiceReport } from '@/server/services/reports.service';
import { GanttChartSquareIcon } from 'lucide-react';
import type { IServiceReport } from '@mixan/db';
interface ReportEditorProps {
report: IServiceReport | null;
}

View File

@@ -6,9 +6,10 @@ import { DataTable } from '@/components/DataTable';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import type { getClientsByOrganizationId } from '@/server/services/clients.service';
import { PlusIcon } from 'lucide-react';
import type { getClientsByOrganizationId } from '@mixan/db';
interface ListClientsProps {
clients: Awaited<ReturnType<typeof getClientsByOrganizationId>>;
}

View File

@@ -1,6 +1,7 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getClientsByOrganizationId } from '@/server/services/clients.service';
import { getClientsByOrganizationId } from '@mixan/db';
import ListClients from './list-clients';

View File

@@ -4,12 +4,13 @@ import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { getOrganizationBySlug } from '@/server/services/organization.service';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { getOrganizationBySlug } from '@mixan/db';
const validator = z.object({
id: z.string().min(2),
name: z.string().min(2),

View File

@@ -2,7 +2,6 @@ import { api } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { zInviteUser } from '@/utils/validation';
import { zodResolver } from '@hookform/resolvers/zod';
import { SendIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
@@ -10,6 +9,8 @@ import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { zInviteUser } from '@mixan/validation';
type IForm = z.infer<typeof zInviteUser>;
export function InviteUser() {

View File

@@ -9,7 +9,8 @@ import {
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { IServiceInvites } from '@/server/services/organization.service';
import type { IServiceInvites } from '@mixan/db';
import { InviteUser } from './invite-user';

View File

@@ -1,11 +1,9 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import {
getInvites,
getOrganizationBySlug,
} from '@/server/services/organization.service';
import { clerkClient } from '@clerk/nextjs';
import { notFound } from 'next/navigation';
import { getInvites, getOrganizationBySlug } from '@mixan/db';
import EditOrganization from './edit-organization';
import InvitedUsers from './invited-users';

View File

@@ -4,13 +4,14 @@ import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { getUserById } from '@/server/services/user.service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { getUserById } from '@mixan/db';
const validator = z.object({
firstName: z.string().min(2),
lastName: z.string().min(2),

View File

@@ -1,8 +1,9 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getUserById } from '@/server/services/user.service';
import { auth } from '@clerk/nextjs';
import { getUserById } from '@mixan/db';
import EditProfile from './edit-profile';
import { Logout } from './logout';

View File

@@ -6,11 +6,12 @@ import { columns } from '@/components/projects/table';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import type { getProjectsByOrganizationId } from '@/server/services/project.service';
import { PlusIcon } from 'lucide-react';
import type { getProjectsByOrganizationSlug } from '@mixan/db';
interface ListProjectsProps {
projects: Awaited<ReturnType<typeof getProjectsByOrganizationId>>;
projects: Awaited<ReturnType<typeof getProjectsByOrganizationSlug>>;
}
export default function ListProjects({ projects }: ListProjectsProps) {
const organizationId = useAppParams().organizationId;

View File

@@ -1,6 +1,7 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { getExists } from '@/server/pageExists';
import { getProjectsByOrganizationSlug } from '@/server/services/project.service';
import { getProjectsByOrganizationSlug } from '@mixan/db';
import ListProjects from './list-projects';

View File

@@ -1,5 +1,5 @@
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { getProjectWithMostEvents } from '@/server/services/project.service';
import { getOrganizationBySlug } from '@mixan/db';
import { getProjectWithMostEvents } from '@mixan/db';
import { notFound, redirect } from 'next/navigation';
import PageLayout from './[projectId]/page-layout';

View File

@@ -1,7 +1,8 @@
import { getCurrentOrganizations } from '@/server/services/organization.service';
import { CreateOrganization } from '@clerk/nextjs';
import { redirect } from 'next/navigation';
import { getCurrentOrganizations } from '@mixan/db';
export default async function Page() {
const organizations = await getCurrentOrganizations();

View File

@@ -11,10 +11,9 @@ import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { notFound } from 'next/navigation';
import { getShareOverviewById } from '@mixan/db';
import { getOrganizationBySlug, getShareOverviewById } from '@mixan/db';
interface PageProps {
params: {

View File

@@ -1,5 +1,4 @@
import { appRouter } from '@/server/api/root';
import { getSession } from '@/server/auth';
import { getAuth } from '@clerk/nextjs/server';
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
@@ -9,9 +8,7 @@ const handler = (req: Request) =>
req,
router: appRouter,
async createContext({ req }) {
console.log('------- createContext --------');
const session = getAuth(req as any);
console.log('session', JSON.stringify(session, null, 2));
return {
session,
};

View File

@@ -0,0 +1,20 @@
import type { MetadataRoute } from 'next';
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'Openpanel.dev',
short_name: 'Openpanel.dev',
description: '',
start_url: '/',
display: 'standalone',
background_color: '#fff',
theme_color: '#fff',
icons: [
{
src: 'https://openpanel.dev/favicon.ico',
sizes: 'any',
type: 'image/x-icon',
},
],
};
}

View File

@@ -2,12 +2,13 @@
import { api } from '@/app/_trpc/client';
import { pushModal, showConfirm } from '@/modals';
import type { IClientWithProject } from '@/types';
import { clipboard } from '@/utils/clipboard';
import { MoreHorizontal } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceClientWithProject } from '@mixan/db';
import { Button } from '../ui/button';
import {
DropdownMenu,
@@ -18,7 +19,7 @@ import {
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
export function ClientActions(client: IClientWithProject) {
export function ClientActions(client: IServiceClientWithProject) {
const { id } = client;
const router = useRouter();
const deletion = api.client.remove.useMutation({

View File

@@ -1,10 +1,11 @@
import type { IClientWithProject } from '@/types';
import { formatDate } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import type { IServiceClientWithProject } from '@mixan/db';
import { ClientActions } from './ClientActions';
export const columns: ColumnDef<IClientWithProject>[] = [
export const columns: ColumnDef<IServiceClientWithProject>[] = [
{
accessorKey: 'name',
header: 'Name',

View File

@@ -8,7 +8,7 @@ import { Button } from '../ui/button';
interface ExpandableListItemProps {
children: React.ReactNode;
content: React.ReactNode;
title: string;
title: React.ReactNode;
image?: React.ReactNode;
initialOpen?: boolean;
className?: string;
@@ -29,7 +29,7 @@ export function ExpandableListItem({
<div className="p-2 sm:p-4 flex gap-4">
<div className="flex gap-1">{image}</div>
<div className="flex flex-col flex-1 gap-1 min-w-0">
<span className="text-md font-medium leading-none mb-1">{title}</span>
<div className="text-md font-medium leading-none mb-1">{title}</div>
{!!content && (
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4">
{content}

View File

@@ -1,80 +0,0 @@
import { api } from '@/app/_trpc/client';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { ChevronRight, HomeIcon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Container } from '../Container';
export function Breadcrumbs() {
const params = useOrganizationParams();
const org = api.organization.get.useQuery(
{
id: params.organizationId,
},
{
enabled: !!params.organizationId,
staleTime: Infinity,
}
);
const pro = api.project.get.useQuery(
{
id: params.projectId,
},
{
enabled: !!params.projectId,
staleTime: Infinity,
}
);
const dashboard = api.dashboard.get.useQuery(
{
id: params.dashboardId,
},
{
enabled: !!params.dashboardId,
staleTime: Infinity,
}
);
return (
<div className="border-b border-border text-xs">
<Container className="flex items-center gap-2 h-8">
{org.isLoading && pro.isLoading && (
<div className="animate-pulse bg-slate-200 h-4 w-24 rounded"></div>
)}
{org.data && (
<>
<HomeIcon size={14} />
<Link shallow href={`/${org.data.id}`}>
{org.data.name}
</Link>
</>
)}
{org.data && pro.data && (
<>
<ChevronRight size={10} />
<Link shallow href={`/${org.data.id}/${pro.data.id}`}>
{pro.data.name}
</Link>
</>
)}
{org.data && pro.data && dashboard.data && (
<>
<ChevronRight size={10} />
<Link
shallow
href={`/${org.data.id}/${pro.data.id}/${dashboard.data.id}`}
>
{dashboard.data.name}
</Link>
</>
)}
</Container>
</div>
);
}

View File

@@ -1,38 +0,0 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { LineChart } from 'lucide-react';
import Link from 'next/link';
export function NavbarCreate() {
const params = useOrganizationParams();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button>Create</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link
shallow
href={`/${params.organizationId}/${params.projectId}/reports`}
>
<LineChart className="mr-2 h-4 w-4" />
<span>Create a report</span>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,60 +0,0 @@
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { cn } from '@/utils/cn';
import type { LinkProps } from 'next/link';
import Link from 'next/link';
import { strip } from '@mixan/common';
import { NavbarUserDropdown } from './NavbarUserDropdown';
function Item({
children,
...props
}: LinkProps & { children: React.ReactNode }) {
return (
<Link
{...props}
className="h-9 items-center flex px-3 leading-none relative [&>div]:hover:opacity-100 [&>div]:hover:ring-1"
shallow
>
<div className="opacity-0 absolute inset-0 transition-all bg-gradient-to-r from-blue-50 to-purple-50 rounded ring-0 ring-purple-900" />
<span className="relative">{children}</span>
</Link>
);
}
export function NavbarMenu() {
const params = useOrganizationParams();
return (
<div className={cn('flex gap-1 items-center text-sm', 'max-sm:flex-col')}>
{params.projectId && (
<Item href={`/${params.organizationId}/${params.projectId}`}>
Dashboards
</Item>
)}
{params.projectId && (
<Item href={`/${params.organizationId}/${params.projectId}/events`}>
Events
</Item>
)}
{params.projectId && (
<Item href={`/${params.organizationId}/${params.projectId}/profiles`}>
Profiles
</Item>
)}
{params.projectId && (
<Item
href={{
pathname: `/${params.organizationId}/${params.projectId}/reports`,
query: strip({
dashboardId: params.dashboardId,
}),
}}
>
Create report
</Item>
)}
<NavbarUserDropdown />
</div>
);
}

View File

@@ -1,70 +0,0 @@
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { User } from 'lucide-react';
import { signOut, useSession } from 'next-auth/react';
import Link from 'next/link';
export function NavbarUserDropdown() {
const params = useOrganizationParams();
const session = useSession();
const user = session.data?.user;
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Avatar>
<AvatarFallback>{user?.name?.charAt(0) ?? '🤠'}</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem asChild className="cursor-pointer">
<Link
href={`/${params.organizationId}/settings/organization`}
shallow
>
<User className="mr-2 h-4 w-4" />
Organization
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={`/${params.organizationId}/settings/projects`} shallow>
<User className="mr-2 h-4 w-4" />
Projects
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={`/${params.organizationId}/settings/clients`} shallow>
<User className="mr-2 h-4 w-4" />
Clients
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={`/${params.organizationId}/settings/profile`} shallow>
<User className="mr-2 h-4 w-4" />
Profile
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600 cursor-pointer"
onClick={() => {
signOut().catch(console.error);
}}
>
<User className="mr-2 h-4 w-4" />
Logout
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,10 +1,13 @@
'use client';
import { Button } from '@/components/ui/button';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
import { Options as NuqsOptions } from 'nuqs';
import type { Options as NuqsOptions } from 'nuqs';
interface OverviewFiltersButtonsProps {
className?: string;
@@ -15,25 +18,40 @@ export function OverviewFiltersButtons({
className,
nuqsOptions,
}: OverviewFiltersButtonsProps) {
const eventQueryFilters = useEventQueryFilters(nuqsOptions);
const filters = Object.entries(eventQueryFilters).filter(
([, filter]) => filter.get !== null
);
if (filters.length === 0) return null;
const [events, setEvents] = useEventQueryNamesFilter(nuqsOptions);
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
if (filters.length === 0 && events.length === 0) return null;
return (
<div className={cn('flex flex-wrap gap-2 px-4 pb-4', className)}>
{filters.map(([key, filter]) => (
{events.map((event) => (
<Button
key={key}
key={event}
size="sm"
variant="outline"
icon={X}
onClick={() => filter.set(null)}
onClick={() => setEvents((p) => p.filter((e) => e !== event))}
>
<span className="mr-1">{key} is</span>
<strong>{filter.get}</strong>
<strong>{event}</strong>
</Button>
))}
{filters.map((filter) => {
if (!filter.value[0]) {
return null;
}
return (
<Button
key={filter.name}
size="sm"
variant="outline"
icon={X}
onClick={() => setFilter(filter.name, filter.value[0], 'is')}
>
<span className="mr-1">{filter.name} is</span>
<strong>{filter.value[0]}</strong>
</Button>
);
})}
</div>
);
}

View File

@@ -1,93 +1,131 @@
'use client';
import { api } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useEventNames } from '@/hooks/useEventNames';
import { useEventProperties } from '@/hooks/useEventProperties';
import {
useEventQueryFilters,
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { useEventValues } from '@/hooks/useEventValues';
import { XIcon } from 'lucide-react';
import { Options as NuqsOptions } from 'nuqs';
import type { Options as NuqsOptions } from 'nuqs';
import type {
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
} from '@mixan/validation';
interface OverviewFiltersProps {
projectId: string;
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
}
export function OverviewFiltersDrawerContent({
projectId,
nuqsOptions,
enableEventsFilter,
}: OverviewFiltersProps) {
const eventQueryFilters = useEventQueryFilters(nuqsOptions);
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
const eventNames = useEventNames(projectId);
const eventProperties = useEventProperties(projectId);
return (
<div>
<h2 className="text-xl font-medium mb-8">Overview filters</h2>
<Combobox
className="w-full"
onChange={(value) => {
// @ts-expect-error
eventQueryFilters[value].set('');
}}
value=""
placeholder="Filter by..."
label="What do you want to filter by?"
items={Object.entries(eventQueryFilters)
.filter(([, filter]) => filter.get === null)
.map(([name]) => ({
label: name,
value: name,
<SheetHeader className="mb-8">
<SheetTitle>Overview filters</SheetTitle>
</SheetHeader>
<div className="flex flex-col gap-4">
{enableEventsFilter && (
<ComboboxAdvanced
className="w-full"
value={event}
onChange={setEvent}
// First items is * which is only used for report editing
items={eventNames.slice(1).map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
)}
<Combobox
className="w-full"
onChange={(value) => {
setFilter(value, '');
}}
value=""
placeholder="Filter by property"
label="What do you want to filter by?"
items={eventProperties.map((item) => ({
label: item,
value: item,
}))}
searchable
/>
searchable
/>
</div>
<div className="flex flex-col gap-4 mt-8">
{Object.entries(eventQueryFilters)
.filter(([, filter]) => filter.get !== null)
.map(([name, filter]) => (
<FilterOption
key={name}
projectId={projectId}
name={name}
{...filter}
/>
))}
{filters
.filter((filter) => filter.value[0] !== null)
.map((filter) => {
return (
<FilterOption
key={filter.name}
projectId={projectId}
setFilter={setFilter}
{...filter}
/>
);
})}
</div>
</div>
);
}
export function FilterOption({
name,
get,
set,
setFilter,
projectId,
}: {
name: string;
get: string | null;
set: (value: string | null) => void;
...filter
}: IChartEventFilter & {
projectId: string;
setFilter: (
name: string,
value: IChartEventFilterValue,
operator: IChartEventFilterOperator
) => void;
}) {
const { data } = api.chart.values.useQuery({
const values = useEventValues(
projectId,
event: name === 'path' ? 'screen_view' : 'session_start',
property: name,
});
filter.name === 'path' ? 'screen_view' : 'session_start',
filter.name
);
return (
<div className="flex gap-2 items-center">
<div>{name}</div>
<div>{filter.name}</div>
<Combobox
className="flex-1"
onChange={(value) => set(value)}
onChange={(value) => setFilter(filter.name, value, filter.operator)}
placeholder={'Select a value'}
items={
data?.values.filter(Boolean).map((value) => ({
value,
label: value,
})) ?? []
}
value={get}
items={values.map((value) => ({
value,
label: value,
}))}
value={String(filter.value[0] ?? '')}
/>
<Button size="icon" variant="ghost" onClick={() => set(null)}>
<Button
size="icon"
variant="ghost"
onClick={() =>
setFilter(filter.name, filter.value[0] ?? '', filter.operator)
}
>
<XIcon />
</Button>
</div>

View File

@@ -3,18 +3,20 @@
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { FilterIcon } from 'lucide-react';
import { Options as NuqsOptions } from 'nuqs';
import type { Options as NuqsOptions } from 'nuqs';
import { OverviewFiltersDrawerContent } from './overview-filters-drawer-content';
interface OverviewFiltersDrawerProps {
projectId: string;
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
}
export function OverviewFiltersDrawer({
projectId,
nuqsOptions,
enableEventsFilter,
}: OverviewFiltersDrawerProps) {
return (
<Sheet>
@@ -27,6 +29,7 @@ export function OverviewFiltersDrawer({
<OverviewFiltersDrawerContent
projectId={projectId}
nuqsOptions={nuqsOptions}
enableEventsFilter={enableEventsFilter}
/>
</SheetContent>
</Sheet>

View File

@@ -1,10 +1,11 @@
'use client';
import type { IChartInput } from '@/types';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon } from 'lucide-react';
import AnimateHeight from 'react-animate-height';
import type { IChartInput } from '@mixan/validation';
import { Chart } from '../report/chart';
import { Widget, WidgetBody, WidgetHead } from '../Widget';
import { useOverviewOptions } from './useOverviewOptions';

View File

@@ -1,10 +1,7 @@
'use client';
import { Chart } from '@/components/report/chart';
import {
useEventFilters,
useEventQueryFilters,
} from '@/hooks/useEventQueryFilters';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
@@ -19,9 +16,7 @@ export default function OverviewTopDevices({
projectId,
}: OverviewTopDevicesProps) {
const { interval, range, previous } = useOverviewOptions();
const filters = useEventFilters();
const { device, browser, browserVersion, os, osVersion } =
useEventQueryFilters();
const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
title: 'Top devices',
@@ -190,21 +185,21 @@ export default function OverviewTopDevices({
onClick={(item) => {
switch (widget.key) {
case 'devices':
device.set(item.name);
setFilter('device', item.name);
break;
case 'browser':
setWidget('browser_version');
browser.set(item.name);
setFilter('browser', item.name);
break;
case 'browser_version':
browserVersion.set(item.name);
setFilter('browser_version', item.name);
break;
case 'os':
setWidget('os_version');
os.set(item.name);
setFilter('os', item.name);
break;
case 'os_version':
osVersion.set(item.name);
setFilter('os_version', item.name);
break;
}
}}

View File

@@ -1,7 +1,7 @@
'use client';
import { Chart } from '@/components/report/chart';
import { useEventFilters } from '@/hooks/useEventQueryFilters';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
@@ -16,7 +16,7 @@ export default function OverviewTopEvents({
projectId,
}: OverviewTopEventsProps) {
const { interval, range, previous } = useOverviewOptions();
const filters = useEventFilters();
const [filters] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: {
title: 'Top events',

View File

@@ -1,10 +1,7 @@
'use client';
import { Chart } from '@/components/report/chart';
import {
useEventFilters,
useEventQueryFilters,
} from '@/hooks/useEventQueryFilters';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
@@ -17,8 +14,7 @@ interface OverviewTopGeoProps {
}
export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const { interval, range, previous } = useOverviewOptions();
const filters = useEventFilters();
const { region, country, city } = useEventQueryFilters();
const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
map: {
title: 'Map',
@@ -160,14 +156,14 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
switch (widget.key) {
case 'countries':
setWidget('regions');
country.set(item.name);
setFilter('country', item.name);
break;
case 'regions':
setWidget('cities');
region.set(item.name);
setFilter('region', item.name);
break;
case 'cities':
city.set(item.name);
setFilter('city', item.name);
break;
}
}}

View File

@@ -1,10 +1,7 @@
'use client';
import { Chart } from '@/components/report/chart';
import {
useEventFilters,
useEventQueryFilters,
} from '@/hooks/useEventQueryFilters';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
@@ -17,8 +14,7 @@ interface OverviewTopPagesProps {
}
export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, previous } = useOverviewOptions();
const filters = useEventFilters();
const { path } = useEventQueryFilters();
const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('pages', {
top: {
title: 'Top pages',
@@ -129,7 +125,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
{...widget.chart}
previous={false}
onClick={(item) => {
path.set(item.name);
setFilter('path', item.name);
}}
/>
</WidgetBody>

View File

@@ -1,10 +1,7 @@
'use client';
import { Chart } from '@/components/report/chart';
import {
useEventFilters,
useEventQueryFilters,
} from '@/hooks/useEventQueryFilters';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
@@ -19,17 +16,7 @@ export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { interval, range, previous } = useOverviewOptions();
const {
referrer,
referrerName,
referrerType,
utmCampaign,
utmContent,
utmMedium,
utmSource,
utmTerm,
} = useEventQueryFilters();
const filters = useEventFilters();
const [filters, setFilter] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('sources', {
all: {
title: 'Top sources',
@@ -282,30 +269,30 @@ export default function OverviewTopSources({
onClick={(item) => {
switch (widget.key) {
case 'all':
referrerName.set(item.name);
setFilter('referrer_name', item.name);
setWidget('domain');
break;
case 'domain':
referrer.set(item.name);
setFilter('referrer', item.name);
break;
case 'type':
referrerType.set(item.name);
setFilter('referrer_type', item.name);
setWidget('domain');
break;
case 'utm_source':
utmSource.set(item.name);
setFilter('utm_source', item.name);
break;
case 'utm_medium':
utmMedium.set(item.name);
setFilter('utm_medium', item.name);
break;
case 'utm_campaign':
utmCampaign.set(item.name);
setFilter('utm_campaign', item.name);
break;
case 'utm_term':
utmTerm.set(item.name);
setFilter('utm_term', item.name);
break;
case 'utm_content':
utmContent.set(item.name);
setFilter('utm_content', item.name);
break;
}
}}

View File

@@ -1,6 +1,3 @@
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { getDefaultIntervalByRange, timeRanges } from '@/utils/constants';
import { mapKeys } from '@/utils/validation';
import {
parseAsBoolean,
parseAsInteger,
@@ -8,6 +5,9 @@ import {
useQueryState,
} from 'nuqs';
import { getDefaultIntervalByRange, timeRanges } from '@mixan/constants';
import { mapKeys } from '@mixan/validation';
const nuqsOptions = { history: 'push' } as const;
export function useOverviewOptions() {

View File

@@ -1,7 +1,8 @@
import type { IChartInput } from '@/types';
import { mapKeys } from '@/utils/validation';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { mapKeys } from '@mixan/validation';
import type { IChartInput } from '@mixan/validation';
export function useOverviewWidget<T extends string>(
key: string,
widgets: Record<T, { title: string; btn: string; chart: IChartInput }>
@@ -15,7 +16,7 @@ export function useOverviewWidget<T extends string>(
);
return [
{
...widgets[widget]!,
...widgets[widget],
key: widget,
},
setWidget,

View File

@@ -1,11 +1,12 @@
'use client';
import type { IServiceProfile } from '@/server/services/profile.service';
import { cn } from '@/utils/cn';
import { AvatarImage } from '@radix-ui/react-avatar';
import type { VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
import type { IServiceProfile } from '@mixan/db';
import { Avatar, AvatarFallback } from '../ui/avatar';
interface ProfileAvatarProps
@@ -41,8 +42,8 @@ export function ProfileAvatar({
size === 'sm'
? 'text-xs'
: size === 'xs'
? 'text-[8px]'
: 'text-base',
? 'text-[8px]'
: 'text-base',
'bg-slate-200 text-slate-800'
)}
>

View File

@@ -2,12 +2,13 @@
import { api } from '@/app/_trpc/client';
import { pushModal, showConfirm } from '@/modals';
import type { IProject } from '@/types';
import { clipboard } from '@/utils/clipboard';
import { MoreHorizontal } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceProject } from '@mixan/db';
import { Button } from '../ui/button';
import {
DropdownMenu,
@@ -18,7 +19,7 @@ import {
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
export function ProjectActions(project: IProject) {
export function ProjectActions(project: Exclude<IServiceProject, null>) {
const { id } = project;
const router = useRouter();
const deletion = api.project.remove.useMutation({

View File

@@ -1,7 +1,7 @@
import { IServiceProject } from '@/server/services/project.service';
import { formatDate } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { IServiceProject } from '@mixan/db';
import type { Project as IProject } from '@mixan/db';
import { ProjectActions } from './ProjectActions';

View File

@@ -1,8 +1,9 @@
import { useDispatch, useSelector } from '@/redux';
import { chartTypes } from '@/utils/constants';
import { objectToZodEnums } from '@/utils/validation';
import { LineChartIcon } from 'lucide-react';
import { chartTypes } from '@mixan/constants';
import { objectToZodEnums } from '@mixan/validation';
import { Combobox } from '../ui/combobox';
import { changeChartType } from './reportSlice';

View File

@@ -1,10 +1,11 @@
import { useDispatch, useSelector } from '@/redux';
import type { IInterval } from '@/types';
import { ClockIcon } from 'lucide-react';
import {
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@/utils/constants';
import { ClockIcon } from 'lucide-react';
} from '@mixan/constants';
import type { IInterval } from '@mixan/validation';
import { Combobox } from '../ui/combobox';
import { changeInterval } from './reportSlice';
@@ -32,7 +33,7 @@ export function ReportInterval({ className }: ReportIntervalProps) {
className={className}
placeholder="Interval"
onChange={(value) => {
dispatch(changeInterval(value as IInterval));
dispatch(changeInterval(value));
}}
value={interval}
items={[

View File

@@ -1,8 +1,9 @@
import { useDispatch, useSelector } from '@/redux';
import { lineTypes } from '@/utils/constants';
import { objectToZodEnums } from '@/utils/validation';
import { Tv2Icon } from 'lucide-react';
import { lineTypes } from '@mixan/constants';
import { objectToZodEnums } from '@mixan/validation';
import { Combobox } from '../ui/combobox';
import { changeLineType } from './reportSlice';

View File

@@ -1,7 +1,8 @@
import type { IChartRange } from '@/types';
import { timeRanges } from '@/utils/constants';
import { CalendarIcon } from 'lucide-react';
import { timeRanges } from '@mixan/constants';
import type { IChartRange } from '@mixan/validation';
import type { ExtendedComboboxProps } from '../ui/combobox';
import { Combobox } from '../ui/combobox';

View File

@@ -10,7 +10,8 @@ import {
useState,
} from 'react';
import type { IChartSerie } from '@/server/api/routers/chart';
import type { IChartInput } from '@/types';
import type { IChartInput } from '@mixan/validation';
import { ChartLoading } from './ChartLoading';
import { MetricCardLoading } from './MetricCard';

View File

@@ -3,11 +3,12 @@
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IChartMetric } from '@/types';
import { theme } from '@/utils/theme';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import type { IChartMetric } from '@mixan/validation';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
interface MetricCardProps {

View File

@@ -4,7 +4,6 @@ import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartLineType, IInterval } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
@@ -16,6 +15,8 @@ import {
YAxis,
} from 'recharts';
import type { IChartLineType, IInterval } from '@mixan/validation';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';

View File

@@ -5,9 +5,10 @@ import type { IChartData } from '@/app/_trpc/client';
import { Progress } from '@/components/ui/progress';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { NOT_SET_VALUE } from '@/utils/constants';
import { getChartColor } from '@/utils/theme';
import { NOT_SET_VALUE } from '@mixan/constants';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon';

View File

@@ -3,7 +3,6 @@ import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
import { useSelector } from '@/redux';
import type { IToolTipProps } from '@/types';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';

View File

@@ -4,11 +4,12 @@ import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IInterval } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor, theme } from '@/utils/theme';
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import type { IInterval } from '@mixan/validation';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';

View File

@@ -6,7 +6,6 @@ import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartLineType, IInterval } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
@@ -18,6 +17,8 @@ import {
YAxis,
} from 'recharts';
import type { IChartLineType, IInterval } from '@mixan/validation';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';

View File

@@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { NOT_SET_VALUE } from '@/utils/constants';
import type { LucideIcon, LucideProps } from 'lucide-react';
import {
ActivityIcon,
@@ -15,6 +14,8 @@ import {
TabletIcon,
} from 'lucide-react';
import { NOT_SET_VALUE } from '@mixan/constants';
interface SerieIconProps extends LucideProps {
name: string;
}

View File

@@ -3,7 +3,8 @@
import { memo, useEffect, useState } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { api } from '@/app/_trpc/client';
import type { IChartInput } from '@/types';
import type { IChartInput } from '@mixan/validation';
import { ChartEmpty } from './ChartEmpty';
import { ChartLoading } from './ChartLoading';

View File

@@ -1,3 +1,12 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import {
alphabetIds,
getDefaultIntervalByRange,
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@mixan/constants';
import type {
IChartBreakdown,
IChartEvent,
@@ -6,15 +15,7 @@ import type {
IChartRange,
IChartType,
IInterval,
} from '@/types';
import {
alphabetIds,
getDefaultIntervalByRange,
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@/utils/constants';
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
} from '@mixan/validation';
type InitialState = IChartInput & {
dirty: boolean;

View File

@@ -2,10 +2,11 @@ import { api } from '@/app/_trpc/client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch } from '@/redux';
import type { IChartEvent } from '@/types';
import { cn } from '@/utils/cn';
import { DatabaseIcon } from 'lucide-react';
import type { IChartEvent } from '@mixan/validation';
import { changeEvent } from '../reportSlice';
interface EventPropertiesComboboxProps {

View File

@@ -5,9 +5,10 @@ import { ColorSquare } from '@/components/ColorSquare';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';
import type { IChartBreakdown } from '@/types';
import { SplitIcon } from 'lucide-react';
import type { IChartBreakdown } from '@mixan/validation';
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
import { ReportBreakdownMore } from './ReportBreakdownMore';
import type { ReportEventMoreProps } from './ReportEventMore';

View File

@@ -1,6 +1,5 @@
'use client';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { Checkbox } from '@/components/ui/checkbox';
@@ -8,10 +7,12 @@ import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams';
import { useDebounceFn } from '@/hooks/useDebounceFn';
import { useEventNames } from '@/hooks/useEventNames';
import { useDispatch, useSelector } from '@/redux';
import type { IChartEvent } from '@/types';
import { GanttChart, GanttChartIcon, Users } from 'lucide-react';
import type { IChartEvent } from '@mixan/validation';
import {
addEvent,
changeEvent,
@@ -29,14 +30,8 @@ export function ReportEvents() {
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
const { projectId } = useAppParams();
const eventNames = useEventNames(projectId);
const eventsQuery = api.chart.events.useQuery({
projectId,
});
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
value: item.name,
label: item.name,
}));
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
dispatch(changeEvent(event));
});
@@ -76,7 +71,10 @@ export function ReportEvents() {
})
);
}}
items={eventsCombobox}
items={eventNames.map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
<Input
@@ -189,7 +187,10 @@ export function ReportEvents() {
})
);
}}
items={eventsCombobox}
items={eventNames.map((item) => ({
label: item.name,
value: item.name,
}))}
placeholder="Select event"
/>
</div>

View File

@@ -7,13 +7,15 @@ import { RenderDots } from '@/components/ui/RenderDots';
import { useAppParams } from '@/hooks/useAppParams';
import { useMappings } from '@/hooks/useMappings';
import { useDispatch } from '@/redux';
import { SlidersHorizontal, Trash } from 'lucide-react';
import { operators } from '@mixan/constants';
import type {
IChartEvent,
IChartEventFilter,
IChartEventFilterOperator,
IChartEventFilterValue,
} from '@/types';
import { operators } from '@/utils/constants';
import { SlidersHorizontal, Trash } from 'lucide-react';
} from '@mixan/validation';
import { mapKeys } from '@mixan/validation';
import { changeEvent } from '../../reportSlice';
@@ -67,7 +69,7 @@ export function FilterItem({ filter, event }: FilterProps) {
);
};
const changeFilterOperator = (operator: IChartEventFilter['operator']) => {
const changeFilterOperator = (operator: IChartEventFilterOperator) => {
dispatch(
changeEvent({
...event,
@@ -104,9 +106,9 @@ export function FilterItem({ filter, event }: FilterProps) {
<div className="flex gap-1">
<Dropdown
onChange={changeFilterOperator}
items={Object.entries(operators).map(([key, value]) => ({
value: key as IChartEventFilter['operator'],
label: value,
items={mapKeys(operators).map((key) => ({
value: key,
label: operators[key],
}))}
label="Operator"
>

View File

@@ -2,9 +2,10 @@ import { api } from '@/app/_trpc/client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch } from '@/redux';
import type { IChartEvent } from '@/types';
import { FilterIcon } from 'lucide-react';
import type { IChartEvent } from '@mixan/validation';
import { changeEvent } from '../../reportSlice';
interface FiltersComboboxProps {

View File

@@ -1,4 +1,4 @@
import type { IChartEvent } from '@/types';
import type { IChartEvent } from '@mixan/validation';
import { FilterItem } from './FilterItem';

View File

@@ -25,6 +25,7 @@ interface ComboboxAdvancedProps {
onChange: React.Dispatch<React.SetStateAction<IValue[]>>;
items: IItem[];
placeholder: string;
className?: string;
}
export function ComboboxAdvanced({
@@ -32,6 +33,7 @@ export function ComboboxAdvanced({
value,
onChange,
placeholder,
className,
}: ComboboxAdvancedProps) {
const [open, setOpen] = React.useState(false);
const [inputValue, setInputValue] = React.useState('');
@@ -81,8 +83,12 @@ export function ComboboxAdvanced({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant={'outline'} onClick={() => setOpen((prev) => !prev)}>
<div className="flex gap-1 flex-wrap">
<Button
variant={'outline'}
onClick={() => setOpen((prev) => !prev)}
className={className}
>
<div className="flex gap-1 flex-wrap w-full">
{value.length === 0 && placeholder}
{value.slice(0, 2).map((value) => {
const item = items.find((item) => item.value === value) ?? {

View File

@@ -1,12 +1,9 @@
import { api } from '@/app/_trpc/client';
export function useEventNames(projectId: string) {
const filterEventsQuery = api.chart.events.useQuery({
const query = api.chart.events.useQuery({
projectId: projectId,
});
return (filterEventsQuery.data ?? []).map((item) => ({
value: item.name,
label: item.name,
}));
return query.data ?? [];
}

View File

@@ -0,0 +1,10 @@
import { api } from '@/app/_trpc/client';
export function useEventProperties(projectId: string, event?: string) {
const query = api.chart.properties.useQuery({
projectId: projectId,
event,
});
return query.data ?? [];
}

View File

@@ -1,311 +0,0 @@
import { useMemo } from 'react';
import type { IChartInput } from '@/types';
import { parseAsString, useQueryState } from 'nuqs';
const nuqsOptions = { history: 'push' } as const;
export function useEventQueryFiltersqweqweqweqweqwe() {
// Path
const [path, setPath] = useQueryState(
'path',
parseAsString.withOptions(nuqsOptions)
);
// Referrer
const [referrer, setReferrer] = useQueryState(
'referrer',
parseAsString.withOptions(nuqsOptions)
);
const [referrerName, setReferrerName] = useQueryState(
'referrer_name',
parseAsString.withOptions(nuqsOptions)
);
const [referrerType, setReferrerType] = useQueryState(
'referrer_type',
parseAsString.withOptions(nuqsOptions)
);
// Sources
const [utmSource, setUtmSource] = useQueryState(
'utm_source',
parseAsString.withOptions(nuqsOptions)
);
const [utmMedium, setUtmMedium] = useQueryState(
'utm_medium',
parseAsString.withOptions(nuqsOptions)
);
const [utmCampaign, setUtmCampaign] = useQueryState(
'utm_campaign',
parseAsString.withOptions(nuqsOptions)
);
const [utmContent, setUtmContent] = useQueryState(
'utm_content',
parseAsString.withOptions(nuqsOptions)
);
const [utmTerm, setUtmTerm] = useQueryState(
'utm_term',
parseAsString.withOptions(nuqsOptions)
);
// Geo
const [country, setCountry] = useQueryState(
'country',
parseAsString.withOptions(nuqsOptions)
);
const [region, setRegion] = useQueryState(
'region',
parseAsString.withOptions(nuqsOptions)
);
const [city, setCity] = useQueryState(
'city',
parseAsString.withOptions(nuqsOptions)
);
// tech
const [device, setDevice] = useQueryState(
'device',
parseAsString.withOptions(nuqsOptions)
);
const [browser, setBrowser] = useQueryState(
'browser',
parseAsString.withOptions(nuqsOptions)
);
const [browserVersion, setBrowserVersion] = useQueryState(
'browser_version',
parseAsString.withOptions(nuqsOptions)
);
const [os, setOS] = useQueryState(
'os',
parseAsString.withOptions(nuqsOptions)
);
const [osVersion, setOSVersion] = useQueryState(
'os_version',
parseAsString.withOptions(nuqsOptions)
);
const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = [];
if (path) {
filters.push({
id: 'path',
operator: 'is',
name: 'path',
value: [path],
});
}
if (device) {
filters.push({
id: 'device',
operator: 'is',
name: 'device',
value: [device],
});
}
if (referrer) {
filters.push({
id: 'referrer',
operator: 'is',
name: 'referrer',
value: [referrer],
});
}
if (referrerName) {
filters.push({
id: 'referrer_name',
operator: 'is',
name: 'referrer_name',
value: [referrerName],
});
}
if (referrerType) {
filters.push({
id: 'referrer_type',
operator: 'is',
name: 'referrer_type',
value: [referrerType],
});
}
if (utmSource) {
filters.push({
id: 'utm_source',
operator: 'is',
name: 'properties.query.utm_source',
value: [utmSource],
});
}
if (utmMedium) {
filters.push({
id: 'utm_medium',
operator: 'is',
name: 'properties.query.utm_medium',
value: [utmMedium],
});
}
if (utmCampaign) {
filters.push({
id: 'utm_campaign',
operator: 'is',
name: 'properties.query.utm_campaign',
value: [utmCampaign],
});
}
if (utmContent) {
filters.push({
id: 'utm_content',
operator: 'is',
name: 'properties.query.utm_content',
value: [utmContent],
});
}
if (utmTerm) {
filters.push({
id: 'utm_term',
operator: 'is',
name: 'properties.query.utm_term',
value: [utmTerm],
});
}
if (country) {
filters.push({
id: 'country',
operator: 'is',
name: 'country',
value: [country],
});
}
if (region) {
filters.push({
id: 'region',
operator: 'is',
name: 'region',
value: [region],
});
}
if (city) {
filters.push({
id: 'city',
operator: 'is',
name: 'city',
value: [city],
});
}
if (browser) {
filters.push({
id: 'browser',
operator: 'is',
name: 'browser',
value: [browser],
});
}
if (browserVersion) {
filters.push({
id: 'browser_version',
operator: 'is',
name: 'browser_version',
value: [browserVersion],
});
}
if (os) {
filters.push({
id: 'os',
operator: 'is',
name: 'os',
value: [os],
});
}
if (osVersion) {
filters.push({
id: 'os_version',
operator: 'is',
name: 'os_version',
value: [osVersion],
});
}
return filters;
}, [
path,
device,
referrer,
referrerName,
referrerType,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
country,
region,
city,
browser,
browserVersion,
os,
osVersion,
]);
return {
// Computed
filters,
// Path
path,
setPath,
// Refs
referrer,
setReferrer,
referrerName,
setReferrerName,
referrerType,
setReferrerType,
// UTM
utmSource,
setUtmSource,
utmMedium,
setUtmMedium,
utmCampaign,
setUtmCampaign,
utmContent,
setUtmContent,
utmTerm,
setUtmTerm,
// GEO
country,
setCountry,
region,
setRegion,
city,
setCity,
// Tech
device,
setDevice,
browser,
setBrowser,
browserVersion,
setBrowserVersion,
os,
setOS,
osVersion,
setOSVersion,
};
}

View File

@@ -1,326 +1,102 @@
import { useMemo } from 'react';
import type { IChartInput } from '@/types';
import { useCallback } from 'react';
// prettier-ignore
import type { Options as NuqsOptions, UseQueryStateReturn } from 'nuqs';
import type { Options as NuqsOptions } from 'nuqs';
import { parseAsString, useQueryState } from 'nuqs';
import {
createParser,
parseAsArrayOf,
parseAsString,
useQueryState,
} from 'nuqs';
const nuqsOptions = { history: 'push' } as const;
function useFix<T>(hook: UseQueryStateReturn<T, undefined>) {
return useMemo(
() => ({
get: hook[0],
set: hook[1],
}),
[hook]
);
}
type Operator = 'is' | 'isNot' | 'contains' | 'doesNotContain';
export const eventQueryFiltersParser = createParser({
parse: (query: string) => {
if (query === '') return [];
const filters = query.split(';');
return (
filters.map((filter) => {
const [key, operator, value] = filter.split(',');
return {
id: key!,
name: key!,
operator: (operator ?? 'is') as Operator,
value: [decodeURIComponent(value!)],
};
}) ?? []
);
},
serialize: (value) => {
return value
.map(
(filter) =>
`${filter.id},${filter.operator},${encodeURIComponent(filter.value[0] ?? '')}`
)
.join(';');
},
});
export function useEventQueryFilters(options: NuqsOptions = {}) {
// Ignore prettier so that we have all one same line
// prettier-ignore
return {
path: useFix(useQueryState('path', parseAsString.withOptions({...nuqsOptions, ...options}))),
referrer: useFix(useQueryState('referrer', parseAsString.withOptions({...nuqsOptions, ...options}))),
referrerName: useFix(useQueryState('referrerName',parseAsString.withOptions({...nuqsOptions, ...options}))),
referrerType: useFix(useQueryState('referrerType',parseAsString.withOptions({...nuqsOptions, ...options}))),
utmSource: useFix(useQueryState('utmSource',parseAsString.withOptions({...nuqsOptions, ...options}))),
utmMedium: useFix(useQueryState('utmMedium',parseAsString.withOptions({...nuqsOptions, ...options}))),
utmCampaign: useFix(useQueryState('utmCampaign',parseAsString.withOptions({...nuqsOptions, ...options}))),
utmContent: useFix(useQueryState('utmContent',parseAsString.withOptions({...nuqsOptions, ...options}))),
utmTerm: useFix(useQueryState('utmTerm', parseAsString.withOptions({...nuqsOptions, ...options}))),
continent: useFix(useQueryState('continent', parseAsString.withOptions({...nuqsOptions, ...options}))),
country: useFix(useQueryState('country', parseAsString.withOptions({...nuqsOptions, ...options}))),
region: useFix(useQueryState('region', parseAsString.withOptions({...nuqsOptions, ...options}))),
city: useFix(useQueryState('city', parseAsString.withOptions({...nuqsOptions, ...options}))),
device: useFix(useQueryState('device', parseAsString.withOptions({...nuqsOptions, ...options}))),
browser: useFix(useQueryState('browser', parseAsString.withOptions({...nuqsOptions, ...options}))),
browserVersion: useFix(useQueryState('browserVersion',parseAsString.withOptions({...nuqsOptions, ...options}))),
os: useFix(useQueryState('os', parseAsString.withOptions({...nuqsOptions, ...options}))),
osVersion: useFix(useQueryState('osVersion',parseAsString.withOptions({...nuqsOptions, ...options}))),
brand: useFix(useQueryState('brand',parseAsString.withOptions({...nuqsOptions, ...options}))),
model: useFix(useQueryState('model',parseAsString.withOptions({...nuqsOptions, ...options}))),
} as const;
const [filters, setFilters] = useQueryState(
'f',
eventQueryFiltersParser.withDefault([]).withOptions({
...nuqsOptions,
...options,
})
);
const setFilter = useCallback(
(
name: string,
value: string | number | boolean | undefined | null,
operator: Operator = 'is'
) => {
setFilters((prev) => {
const exists = prev.find((filter) => filter.name === name);
if (exists) {
// If same value is already set, remove the filter
if (exists.value[0] === value) {
return prev.filter((filter) => filter.name !== name);
}
return prev.map((filter) => {
if (filter.name === name) {
return {
...filter,
operator,
value: [String(value)],
};
}
return filter;
});
}
return [
...prev,
{
id: name,
name,
operator,
value: [String(value)],
},
];
});
},
[setFilters]
);
return [filters, setFilter, setFilters] as const;
}
export function useEventFilters() {
const eventQueryFilters = useEventQueryFilters();
export const eventQueryNamesFilter = parseAsArrayOf(parseAsString).withDefault(
[]
);
const filters = useMemo(() => {
return getEventFilters({
path: eventQueryFilters.path.get,
device: eventQueryFilters.device.get,
referrer: eventQueryFilters.referrer.get,
referrerName: eventQueryFilters.referrerName.get,
referrerType: eventQueryFilters.referrerType.get,
utmSource: eventQueryFilters.utmSource.get,
utmMedium: eventQueryFilters.utmMedium.get,
utmCampaign: eventQueryFilters.utmCampaign.get,
utmContent: eventQueryFilters.utmContent.get,
utmTerm: eventQueryFilters.utmTerm.get,
continent: eventQueryFilters.continent.get,
country: eventQueryFilters.country.get,
region: eventQueryFilters.region.get,
city: eventQueryFilters.city.get,
browser: eventQueryFilters.browser.get,
browserVersion: eventQueryFilters.browserVersion.get,
os: eventQueryFilters.os.get,
osVersion: eventQueryFilters.osVersion.get,
brand: eventQueryFilters.brand.get,
model: eventQueryFilters.model.get,
});
}, [
eventQueryFilters.path.get,
eventQueryFilters.device.get,
eventQueryFilters.referrer.get,
eventQueryFilters.referrerName.get,
eventQueryFilters.referrerType.get,
eventQueryFilters.utmSource.get,
eventQueryFilters.utmMedium.get,
eventQueryFilters.utmCampaign.get,
eventQueryFilters.utmContent.get,
eventQueryFilters.utmTerm.get,
eventQueryFilters.continent.get,
eventQueryFilters.country.get,
eventQueryFilters.region.get,
eventQueryFilters.city.get,
eventQueryFilters.browser.get,
eventQueryFilters.browserVersion.get,
eventQueryFilters.os.get,
eventQueryFilters.osVersion.get,
eventQueryFilters.model.get,
eventQueryFilters.brand.get,
]);
return filters;
}
export function getEventFilters({
path,
device,
referrer,
referrerName,
referrerType,
utmSource,
utmMedium,
utmCampaign,
utmContent,
utmTerm,
continent,
country,
region,
city,
browser,
browserVersion,
os,
osVersion,
brand,
model,
}: {
path: string | null;
device: string | null;
referrer: string | null;
referrerName: string | null;
referrerType: string | null;
utmSource: string | null;
utmMedium: string | null;
utmCampaign: string | null;
utmContent: string | null;
utmTerm: string | null;
continent: string | null;
country: string | null;
region: string | null;
city: string | null;
browser: string | null;
browserVersion: string | null;
os: string | null;
osVersion: string | null;
brand: string | null;
model: string | null;
}) {
const filters: IChartInput['events'][number]['filters'] = [];
if (path) {
filters.push({
id: 'path',
operator: 'is',
name: 'path' as const,
value: [path],
});
}
if (device) {
filters.push({
id: 'device',
operator: 'is',
name: 'device' as const,
value: [device],
});
}
if (referrer) {
filters.push({
id: 'referrer',
operator: 'is',
name: 'referrer' as const,
value: [referrer],
});
}
if (referrerName) {
filters.push({
id: 'referrerName',
operator: 'is',
name: 'referrer_name' as const,
value: [referrerName],
});
}
if (referrerType) {
filters.push({
id: 'referrerType',
operator: 'is',
name: 'referrer_type' as const,
value: [referrerType],
});
}
if (utmSource) {
filters.push({
id: 'utmSource',
operator: 'is',
name: 'properties.query.utm_source' as const,
value: [utmSource],
});
}
if (utmMedium) {
filters.push({
id: 'utmMedium',
operator: 'is',
name: 'properties.query.utm_medium' as const,
value: [utmMedium],
});
}
if (utmCampaign) {
filters.push({
id: 'utmCampaign',
operator: 'is',
name: 'properties.query.utm_campaign' as const,
value: [utmCampaign],
});
}
if (utmContent) {
filters.push({
id: 'utmContent',
operator: 'is',
name: 'properties.query.utm_content' as const,
value: [utmContent],
});
}
if (utmTerm) {
filters.push({
id: 'utmTerm',
operator: 'is',
name: 'properties.query.utm_term' as const,
value: [utmTerm],
});
}
if (continent) {
filters.push({
id: 'continent',
operator: 'is',
name: 'continent' as const,
value: [continent],
});
}
if (country) {
filters.push({
id: 'country',
operator: 'is',
name: 'country' as const,
value: [country],
});
}
if (region) {
filters.push({
id: 'region',
operator: 'is',
name: 'region' as const,
value: [region],
});
}
if (city) {
filters.push({
id: 'city',
operator: 'is',
name: 'city' as const,
value: [city],
});
}
if (browser) {
filters.push({
id: 'browser',
operator: 'is',
name: 'browser' as const,
value: [browser],
});
}
if (browserVersion) {
filters.push({
id: 'browserVersion',
operator: 'is',
name: 'browser_version' as const,
value: [browserVersion],
});
}
if (os) {
filters.push({
id: 'os',
operator: 'is',
name: 'os' as const,
value: [os],
});
}
if (osVersion) {
filters.push({
id: 'osVersion',
operator: 'is',
name: 'os_version' as const,
value: [osVersion],
});
}
if (brand) {
filters.push({
id: 'brand',
operator: 'is',
name: 'brand' as const,
value: [brand],
});
}
if (model) {
filters.push({
id: 'model',
operator: 'is',
name: 'model' as const,
value: [model],
});
}
return filters;
export function useEventQueryNamesFilter(options: NuqsOptions = {}) {
return useQueryState('events', eventQueryNamesFilter.withOptions(options));
}

View File

@@ -0,0 +1,15 @@
import { api } from '@/app/_trpc/client';
export function useEventValues(
projectId: string,
event: string,
property: string
) {
const query = api.chart.values.useQuery({
projectId: projectId,
event,
property,
});
return query.data?.values ?? [];
}

View File

@@ -1,4 +1,4 @@
import type { IInterval } from '@/types';
import type { IInterval } from '@mixan/validation';
export function formatDateInterval(interval: IInterval, date: Date): string {
if (interval === 'hour' || interval === 'minute') {

View File

@@ -5,7 +5,6 @@ import { authMiddleware } from '@clerk/nextjs';
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
export default authMiddleware({
publicRoutes: ['/share/overview/:id', '/api/trpc(.*)'],
debug: true,
});
export const config = {

View File

@@ -4,17 +4,18 @@ import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import type { IClientWithProject } from '@/types';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { IServiceClient } from '@mixan/db';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type EditClientProps = IClientWithProject;
type EditClientProps = IServiceClient;
const validator = z.object({
id: z.string().min(1),

View File

@@ -4,17 +4,18 @@ import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import type { IServiceDashboardWithProject } from '@/server/services/dashboard.service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { IServiceDashboard } from '@mixan/db';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type EditDashboardProps = IServiceDashboardWithProject;
type EditDashboardProps = Exclude<IServiceDashboard, null>;
const validator = z.object({
id: z.string().min(1),

View File

@@ -4,17 +4,18 @@ import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import type { IProject } from '@/types';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { IServiceProject } from '@mixan/db';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type EditProjectProps = IProject;
type EditProjectProps = Exclude<IServiceProject, null>;
const validator = z.object({
id: z.string().min(1),

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