diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 530515f6..b5fb8baf 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -45,6 +45,7 @@ "@radix-ui/react-tooltip": "^1.0.7", "@reduxjs/toolkit": "^1.9.7", "@t3-oss/env-nextjs": "^0.7.3", + "@tailwindcss/container-queries": "^0.1.1", "@tanstack/react-query": "^4.36.1", "@tanstack/react-table": "^8.11.8", "@trpc/client": "^10.45.1", diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index bc43df5a..8f3ff0ec 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -25,7 +25,7 @@ export default function RootLayout({ return ( diff --git a/apps/dashboard/src/components/report/chart/SerieIcon.tsx b/apps/dashboard/src/components/report/chart/SerieIcon.tsx index 9b3ca76d..c06edff2 100644 --- a/apps/dashboard/src/components/report/chart/SerieIcon.tsx +++ b/apps/dashboard/src/components/report/chart/SerieIcon.tsx @@ -1,5 +1,4 @@ import { useMemo } from 'react'; -import { Tooltiper } from '@/components/ui/tooltip'; import type { LucideIcon, LucideProps } from 'lucide-react'; import { ActivityIcon, @@ -82,10 +81,8 @@ export function SerieIcon({ name, ...props }: SerieIconProps) { }, [name]); return Icon ? ( - -
- -
-
+
+ +
) : null; } diff --git a/apps/dashboard/src/components/report/chart/index.tsx b/apps/dashboard/src/components/report/chart/index.tsx index 090fd72e..a32f5f07 100644 --- a/apps/dashboard/src/components/report/chart/index.tsx +++ b/apps/dashboard/src/components/report/chart/index.tsx @@ -1,8 +1,5 @@ 'use client'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { TriangleIcon } from 'lucide-react'; - import type { IChartInput } from '@openpanel/validation'; import { Funnel } from '../funnel'; @@ -15,18 +12,7 @@ export const ChartSwitch = withChartProivder(function ChartSwitch( props: ReportChartProps ) { if (props.chartType === 'funnel') { - return ( - <> - - - Keep in mind - - Funnel chart is still experimental and might not work as expected. - - - - - ); + return ; } return ; diff --git a/apps/dashboard/src/components/report/funnel/Funnel.old.tsx b/apps/dashboard/src/components/report/funnel/Funnel.old.tsx new file mode 100644 index 00000000..7f50d4f9 --- /dev/null +++ b/apps/dashboard/src/components/report/funnel/Funnel.old.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from '@/components/ui/carousel'; +import type { RouterOutputs } from '@/trpc/client'; +import { cn } from '@/utils/cn'; +import { round } from '@/utils/math'; +import { ArrowRightIcon } from 'lucide-react'; + +import { useChartContext } from '../chart/ChartProvider'; + +function FunnelChart({ from, to }: { from: number; to: number }) { + const fromY = 100 - from; + const toY = 100 - to; + const steps = [ + `M0,${fromY}`, + 'L0,100', + 'L100,100', + `L100,${toY}`, + `L0,${fromY}`, + ]; + return ( + + + + {/* bottom */} + + {/* top */} + + + + {/* bottom */} + + {/* top */} + + + + + + + ); +} + +function getDropoffColor(value: number) { + if (value > 80) { + return 'text-red-600'; + } + if (value > 50) { + return 'text-orange-600'; + } + if (value > 30) { + return 'text-yellow-600'; + } + return 'text-green-600'; +} + +export function FunnelSteps({ + current: { steps, totalSessions }, +}: RouterOutputs['chart']['funnel']) { + const { editMode } = useChartContext(); + return ( + + + + {steps.map((step, index, list) => { + const finalStep = index === list.length - 1; + return ( + +
+
+

Step {index + 1}

+

+ {step.event.displayName || step.event.name} +

+
+
+ +
+
+ Sessions +
+
+ + {step.before} + + + {step.current} +
+ {index !== 0 && ( + <> +
+ {step.current} of {totalSessions} ( + {round(step.percent, 1)}%) +
+ + )} +
+
+ {finalStep ? ( +
+
+ Conversion +
+
+ {round(step.percent, 1)}% +
+
+ Converted {step.current} of {totalSessions} sessions +
+
+ ) : ( +
+
Dropoff
+
+ {round(step.dropoff.percent, 1)}% +
+
+ Lost {step.dropoff.count} sessions +
+
+ )} +
+
+ ); + })} + +
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/report/funnel/Funnel.tsx b/apps/dashboard/src/components/report/funnel/Funnel.tsx index 295798ee..5a96ce8e 100644 --- a/apps/dashboard/src/components/report/funnel/Funnel.tsx +++ b/apps/dashboard/src/components/report/funnel/Funnel.tsx @@ -1,175 +1,225 @@ 'use client'; -import { - Carousel, - CarouselContent, - CarouselItem, - CarouselNext, - CarouselPrevious, -} from '@/components/ui/carousel'; +import { ColorSquare } from '@/components/color-square'; +import { AutoSizer } from '@/components/react-virtualized-auto-sizer'; +import { Progress } from '@/components/ui/progress'; +import { Widget, WidgetBody } from '@/components/widget'; +import { pushModal } from '@/modals'; +import { useSelector } from '@/redux'; import type { RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; import { round } from '@/utils/math'; -import { ArrowRight, ArrowRightIcon } from 'lucide-react'; +import { getChartColor } from '@/utils/theme'; +import { AlertCircleIcon } from 'lucide-react'; +import { last } from 'ramda'; +import { Cell, Pie, PieChart } from 'recharts'; + +import type { IChartInput } from '@openpanel/validation'; import { useChartContext } from '../chart/ChartProvider'; -function FunnelChart({ from, to }: { from: number; to: number }) { - const fromY = 100 - from; - const toY = 100 - to; - const steps = [ - `M0,${fromY}`, - 'L0,100', - 'L100,100', - `L100,${toY}`, - `L0,${fromY}`, - ]; +const findMostDropoffs = ( + steps: RouterOutputs['chart']['funnel']['current']['steps'] +) => { + return steps.reduce((acc, step) => { + if (step.dropoffCount > acc.dropoffCount) { + return step; + } + return acc; + }); +}; + +function InsightCard({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { return ( - - - - {/* bottom */} - - {/* top */} - - - - {/* bottom */} - - {/* top */} - - - - - - +
+ {title} +
{children}
+
); } -function getDropoffColor(value: number) { - if (value > 80) { - return 'text-red-600'; - } - if (value > 50) { - return 'text-orange-600'; - } - if (value > 30) { - return 'text-yellow-600'; - } - return 'text-green-600'; -} +type Props = RouterOutputs['chart']['funnel'] & { + input: IChartInput; +}; export function FunnelSteps({ - steps, - totalSessions, -}: RouterOutputs['chart']['funnel']) { + current: { steps, totalSessions }, + previous, + input, +}: Props) { const { editMode } = useChartContext(); - return ( - - - - {steps.map((step, index, list) => { - const finalStep = index === list.length - 1; + const mostDropoffs = findMostDropoffs(steps); + const lastStep = last(steps)!; + const prevLastStep = last(previous.steps)!; + const hasIncreased = lastStep.percent > prevLastStep.percent; + const withWidget = (children: React.ReactNode) => { + if (editMode) { + return ( +
+ + {children} + +
+ ); + } + + return children; + }; + + return withWidget( +
+
+
+
+ + {({ width }) => { + const height = width; + return ( +
+ + + + + + +
+
{round(lastStep.percent, 2)}%
+
+
+ ); + }} +
+
+
+
Insights
+
+ + {lastStep.count} + of + {totalSessions} + + + {round(lastStep.percent, 2)}% + compared to + + {round(prevLastStep.percent, 2)}% + + + + + {mostDropoffs.event.displayName} + + lost + + {mostDropoffs.dropoffCount} sessions + + +
+
+
+
+
+ {steps.map((step, index) => { + const percent = (step.count / totalSessions) * 100; + const isMostDropoffs = mostDropoffs.event.id === step.event.id; return ( - -
-
-

Step {index + 1}

-

- {step.event.displayName || step.event.name} -

+
+ + {step.event.id} + +
+ {step.event.displayName.replace(/_/g, ' ')}
-
- -
-
- Sessions -
-
- - {step.before} - - - {step.current} -
- {index !== 0 && ( - <> -
- {step.current} of {totalSessions} ( - {round(step.percent, 1)}%) -
- - )} +
+
+ + Total: + + {step.previousCount}
-
- {finalStep ? ( -
-
- Conversion -
-
+ + Dropoff: + + - {round(step.percent, 1)}% -
-
- Converted {step.current} of {totalSessions} sessions + {isMostDropoffs && } + {step.dropoffCount} + +
+
+ + Current: + +
+ {step.count} +
- ) : ( -
-
Dropoff
-
- {round(step.dropoff.percent, 1)}% -
-
- Lost {step.dropoff.count} sessions -
-
- )} +
- + +
); })} - - - - - +
+
); } diff --git a/apps/dashboard/src/components/report/funnel/index.tsx b/apps/dashboard/src/components/report/funnel/index.tsx index 0bb4a8a2..e3d4f4fe 100644 --- a/apps/dashboard/src/components/report/funnel/index.tsx +++ b/apps/dashboard/src/components/report/funnel/index.tsx @@ -19,35 +19,33 @@ export const Funnel = withChartProivder(function Chart({ range, projectId, }: ReportChartProps) { - const [data] = api.chart.funnel.useSuspenseQuery( - { - events, - name, - range, - projectId, - lineType: 'monotone', - interval: 'day', - chartType: 'funnel', - breakdowns: [], - startDate: null, - endDate: null, - previous: false, - formula: undefined, - unit: undefined, - metric: 'sum', - }, - { - keepPreviousData: true, - } - ); + const input: IChartInput = { + events, + name, + range, + projectId, + lineType: 'monotone', + interval: 'day', + chartType: 'funnel', + breakdowns: [], + startDate: null, + endDate: null, + previous: false, + formula: undefined, + unit: undefined, + metric: 'sum', + }; + const [data] = api.chart.funnel.useSuspenseQuery(input, { + keepPreviousData: true, + }); - if (data.steps.length === 0) { + if (data.current.steps.length === 0) { return ; } return ( -
- +
+
); }); diff --git a/apps/dashboard/src/components/ui/progress.tsx b/apps/dashboard/src/components/ui/progress.tsx index 77bd3f92..a8efe21a 100644 --- a/apps/dashboard/src/components/ui/progress.tsx +++ b/apps/dashboard/src/components/ui/progress.tsx @@ -1,28 +1,37 @@ import * as React from 'react'; import { cn } from '@/utils/cn'; +import { round } from '@/utils/math'; import * as ProgressPrimitive from '@radix-ui/react-progress'; const Progress = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef & { color: string; + size?: 'sm' | 'default' | 'lg'; } ->(({ className, value, color, ...props }, ref) => ( +>(({ className, value, color, size = 'default', ...props }, ref) => ( + {value && size != 'sm' && ( +
+
{round(value, 2)}%
+
+ )}
)); Progress.displayName = ProgressPrimitive.Root.displayName; diff --git a/apps/dashboard/src/components/widget-table.tsx b/apps/dashboard/src/components/widget-table.tsx index eecaeaef..a2757101 100644 --- a/apps/dashboard/src/components/widget-table.tsx +++ b/apps/dashboard/src/components/widget-table.tsx @@ -18,7 +18,7 @@ export function WidgetTable({ }: Props) { return ( - + {columns.map((column) => ( diff --git a/apps/dashboard/src/modals/FunnelStepDetails.tsx b/apps/dashboard/src/modals/FunnelStepDetails.tsx new file mode 100644 index 00000000..d8978638 --- /dev/null +++ b/apps/dashboard/src/modals/FunnelStepDetails.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { ListPropertiesIcon } from '@/components/events/list-properties-icon'; +import { Pagination } from '@/components/pagination'; +import { ProfileAvatar } from '@/components/profiles/profile-avatar'; +import { DialogContent } from '@/components/ui/dialog'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tooltiper } from '@/components/ui/tooltip'; +import { WidgetTable } from '@/components/widget-table'; +import { useAppParams } from '@/hooks/useAppParams'; +import { api } from '@/trpc/client'; +import { getProfileName } from '@/utils/getters'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import type { IChartInput } from '@openpanel/validation'; + +import { popModal } from '.'; +import { ModalHeader } from './Modal/Container'; + +interface Props extends IChartInput { + step: number; +} + +function usePrevious(value: any) { + const ref = useRef(); + useEffect(() => { + ref.current = value; + }); + return ref.current; +} + +export default function FunnelStepDetails(props: Props) { + const [data] = api.chart.funnelStep.useSuspenseQuery(props); + const pathname = usePathname(); + const prev = usePrevious(pathname); + const { organizationSlug, projectId } = useAppParams(); + const [page, setPage] = useState(0); + + useEffect(() => { + if (prev && prev !== pathname) { + popModal(); + } + }, [pathname]); + + return ( + +
+ + +
+ + item.id} + columns={[ + { + name: 'Name', + render(profile) { + return ( + + + {getProfileName(profile)} + + ); + }, + }, + { + name: '', + render(profile) { + return ; + }, + }, + { + name: 'Last seen', + render(profile) { + return ( + +
+ {profile.createdAt.toLocaleTimeString()} +
+
+ ); + }, + }, + ]} + /> +
+
+ ); +} diff --git a/apps/dashboard/src/modals/index.tsx b/apps/dashboard/src/modals/index.tsx index f80fc0bc..2442ed4b 100644 --- a/apps/dashboard/src/modals/index.tsx +++ b/apps/dashboard/src/modals/index.tsx @@ -56,6 +56,9 @@ const modals = { VerifyEmail: dynamic(() => import('./VerifyEmail'), { loading: Loading, }), + FunnelStepDetails: dynamic(() => import('./FunnelStepDetails'), { + loading: Loading, + }), }; export const { pushModal, popModal, popAllModals, ModalProvider } = diff --git a/apps/dashboard/src/trpc/api/routers/chart.helpers.ts b/apps/dashboard/src/trpc/api/routers/chart.helpers.ts index 867d4b0d..830ddddf 100644 --- a/apps/dashboard/src/trpc/api/routers/chart.helpers.ts +++ b/apps/dashboard/src/trpc/api/routers/chart.helpers.ts @@ -12,6 +12,8 @@ import { formatClickhouseDate, getChartSql, getEventFiltersWhereClause, + getProfiles, + transformProfile, } from '@openpanel/db'; import type { IChartEvent, @@ -183,7 +185,6 @@ export function withFormula( const scope = { [serie.event.id]: item?.count ?? 0, }; - const count = mathjs .parse(formula) .compile() @@ -418,8 +419,17 @@ export function getChartPrevStartEndDate({ }; } -export async function getFunnelData({ projectId, ...payload }: IChartInput) { - const { startDate, endDate } = getChartStartEndDate(payload); +const ONE_DAY_IN_SECONDS = 60 * 60 * 24; + +export async function getFunnelData({ + projectId, + startDate, + endDate, + ...payload +}: IChartInput) { + if (!startDate || !endDate) { + throw new Error('startDate and endDate are required'); + } if (payload.events.length === 0) { return { @@ -437,9 +447,13 @@ export async function getFunnelData({ projectId, ...payload }: IChartInput) { const innerSql = `SELECT session_id, - windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level + windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level FROM events - WHERE (project_id = ${escape(projectId)} AND created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}') + WHERE + project_id = ${escape(projectId)} AND + created_at >= '${formatClickhouseDate(startDate)}' AND + created_at <= '${formatClickhouseDate(endDate)}' AND + name IN (${payload.events.map((event) => escape(event.name)).join(', ')}) GROUP BY session_id`; const sql = `SELECT level, count() AS count FROM (${innerSql}) GROUP BY level ORDER BY level DESC`; @@ -491,31 +505,29 @@ export async function getFunnelData({ projectId, ...payload }: IChartInput) { .reduce( (acc, item, index, list) => { const prev = list[index - 1] ?? { count: totalSessions }; + const event = payload.events[item.level - 1]!; return [ ...acc, { - event: payload.events[item.level - 1]!, - before: prev.count, - current: item.count, - dropoff: { - count: prev.count - item.count, - percent: 100 - (item.count / prev.count) * 100, + event: { + ...event, + displayName: event.displayName ?? event.name, }, + count: item.count, percent: (item.count / totalSessions) * 100, - prevPercent: (prev.count / totalSessions) * 100, + dropoffCount: prev.count - item.count, + dropoffPercent: 100 - (item.count / prev.count) * 100, + previousCount: prev.count, }, ]; }, [] as { - event: IChartEvent; - before: number; - current: number; - dropoff: { - count: number; - percent: number; - }; + event: IChartEvent & { displayName: string }; + count: number; percent: number; - prevPercent: number; + dropoffCount: number; + dropoffPercent: number; + previousCount: number; }[] ); @@ -525,6 +537,63 @@ export async function getFunnelData({ projectId, ...payload }: IChartInput) { }; } +export async function getFunnelStep({ + projectId, + startDate, + endDate, + step, + ...payload +}: IChartInput & { + step: number; +}) { + if (!startDate || !endDate) { + throw new Error('startDate and endDate are required'); + } + + if (payload.events.length === 0) { + throw new Error('no events selected'); + } + + const funnels = payload.events.map((event) => { + const { sb, getWhere } = createSqlBuilder(); + sb.where = getEventFiltersWhereClause(event.filters); + sb.where.name = `name = ${escape(event.name)}`; + return getWhere().replace('WHERE ', ''); + }); + + const innerSql = `SELECT + session_id, + windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level + FROM events + WHERE + project_id = ${escape(projectId)} AND + created_at >= '${formatClickhouseDate(startDate)}' AND + created_at <= '${formatClickhouseDate(endDate)}' AND + name IN (${payload.events.map((event) => escape(event.name)).join(', ')}) + GROUP BY session_id`; + + const profileIdsQuery = `WITH sessions AS (${innerSql}) + SELECT + DISTINCT e.profile_id as id + FROM sessions s + JOIN events e ON s.session_id = e.session_id + WHERE + s.level = ${step} AND + e.project_id = ${escape(projectId)} AND + e.created_at >= '${formatClickhouseDate(startDate)}' AND + e.created_at <= '${formatClickhouseDate(endDate)}' AND + name IN (${payload.events.map((event) => escape(event.name)).join(', ')}) + ORDER BY e.created_at DESC + LIMIT 500 + `; + + const res = await chQuery<{ + id: string; + }>(profileIdsQuery); + + return getProfiles({ ids: res.map((r) => r.id) }); +} + export async function getSeriesFromEvents(input: IChartInput) { const { startDate, endDate } = input.startDate && input.endDate diff --git a/apps/dashboard/src/trpc/api/routers/chart.ts b/apps/dashboard/src/trpc/api/routers/chart.ts index d5756083..3e3a923f 100644 --- a/apps/dashboard/src/trpc/api/routers/chart.ts +++ b/apps/dashboard/src/trpc/api/routers/chart.ts @@ -16,6 +16,7 @@ import { getChartPrevStartEndDate, getChartStartEndDate, getFunnelData, + getFunnelStep, getSeriesFromEvents, } from './chart.helpers'; @@ -150,9 +151,34 @@ export const chartRouter = createTRPCRouter({ }), funnel: publicProcedure.input(zChartInput).query(async ({ input }) => { - return getFunnelData(input); + const currentPeriod = getChartStartEndDate(input); + const previousPeriod = getChartPrevStartEndDate({ + range: input.range, + ...currentPeriod, + }); + + const [current, previous] = await Promise.all([ + getFunnelData({ ...input, ...currentPeriod }), + getFunnelData({ ...input, ...previousPeriod }), + ]); + + return { + current, + previous, + }; }), + funnelStep: publicProcedure + .input( + zChartInput.extend({ + step: z.number(), + }) + ) + .query(async ({ input }) => { + const currentPeriod = getChartStartEndDate(input); + return getFunnelStep({ ...input, ...currentPeriod }); + }), + // TODO: Make this private chart: publicProcedure.input(zChartInput).query(async ({ input }) => { const currentPeriod = getChartStartEndDate(input); @@ -189,7 +215,10 @@ export const chartRouter = createTRPCRouter({ return { name: serie.name, - event: serie.event, + event: { + ...serie.event, + displayName: serie.event.displayName ?? serie.event.name, + }, metrics: { ...metrics, previous: { diff --git a/apps/dashboard/tailwind.config.js b/apps/dashboard/tailwind.config.js index 223b3199..44e39dae 100644 --- a/apps/dashboard/tailwind.config.js +++ b/apps/dashboard/tailwind.config.js @@ -155,7 +155,10 @@ const config = { }, }, }, - plugins: [require('tailwindcss-animate')], + plugins: [ + require('@tailwindcss/container-queries'), + require('tailwindcss-animate'), + ], }; export default config; diff --git a/packages/db/src/services/profile.service.ts b/packages/db/src/services/profile.service.ts index 5cfa16f3..050eb9d6 100644 --- a/packages/db/src/services/profile.service.ts +++ b/packages/db/src/services/profile.service.ts @@ -122,8 +122,7 @@ export async function getProfilesByExternalId( ${getProfileSelectFields()} FROM profiles GROUP BY id - HAVING project_id = ${escape(projectId)} AND external_id = ${escape(externalId)} - ` + HAVING project_id = ${escape(projectId)} AND external_id = ${escape(externalId)}` ); return data.map(transformProfile); @@ -169,7 +168,7 @@ export interface IServiceUpsertProfile { properties?: Record; } -function transformProfile({ +export function transformProfile({ max_created_at, first_name, last_name, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 238c6717..3efae285 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -225,6 +225,9 @@ importers: '@t3-oss/env-nextjs': specifier: ^0.7.3 version: 0.7.3(typescript@5.3.3)(zod@3.22.4) + '@tailwindcss/container-queries': + specifier: ^0.1.1 + version: 0.1.1(tailwindcss@3.4.1) '@tanstack/react-query': specifier: ^4.36.1 version: 4.36.1(react-dom@18.2.0)(react@18.2.0) @@ -6879,6 +6882,14 @@ packages: zod: 3.22.4 dev: false + /@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.1): + resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==} + peerDependencies: + tailwindcss: '>=3.2.0' + dependencies: + tailwindcss: 3.4.1 + dev: false + /@tailwindcss/typography@0.5.10(tailwindcss@3.4.1): resolution: {integrity: sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==} peerDependencies: @@ -14825,22 +14836,6 @@ packages: camelcase-css: 2.0.1 postcss: 8.4.35 - /postcss-load-config@4.0.2: - resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} - engines: {node: '>= 14'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - dependencies: - lilconfig: 3.1.0 - yaml: 2.3.4 - dev: true - /postcss-load-config@4.0.2(postcss@8.4.35): resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} engines: {node: '>= 14'} @@ -17141,7 +17136,7 @@ packages: execa: 5.1.1 globby: 11.1.0 joycon: 3.1.1 - postcss-load-config: 4.0.2 + postcss-load-config: 4.0.2(postcss@8.4.35) resolve-from: 5.0.0 rollup: 4.12.0 source-map: 0.8.0-beta.0
{column.name}