diff --git a/components/input/DateFilter.js b/components/input/DateFilter.js
index b6c1ee72..ecdf9039 100644
--- a/components/input/DateFilter.js
+++ b/components/input/DateFilter.js
@@ -9,7 +9,7 @@ import useApi from 'hooks/useApi';
import useDateRange from 'hooks/useDateRange';
import useMessages from 'hooks/useMessages';
-export function DateFilter({ websiteId, value, className }) {
+export function DateFilter({ websiteId, value, className, onChange, isForm, alignment }) {
const { formatMessage, labels } = useMessages();
const { get } = useApi();
const [dateRange, setDateRange] = useDateRange(websiteId);
@@ -21,10 +21,26 @@ export function DateFilter({ websiteId, value, className }) {
const data = await get(`/websites/${websiteId}`);
if (data) {
- setDateRange({ value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) });
+ const websiteRange = { value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) };
+
+ if (!isForm) {
+ setDateRange(websiteRange);
+ }
+
+ if (onChange) {
+ onChange(websiteRange);
+ }
}
} else if (value !== 'all') {
- setDateRange(value);
+ if (!isForm) {
+ setDateRange(value);
+ }
+
+ if (onChange) {
+ onChange(value);
+ }
+
+ console.log(value);
}
}
@@ -103,7 +119,7 @@ export function DateFilter({ websiteId, value, className }) {
items={options}
renderValue={renderValue}
value={value}
- alignment="end"
+ alignment={alignment || 'end'}
onChange={handleChange}
>
{({ label, value, divider }) => (
diff --git a/components/metrics/WebsiteChart.js b/components/metrics/WebsiteChart.js
index 6614d40f..cab0821e 100644
--- a/components/metrics/WebsiteChart.js
+++ b/components/metrics/WebsiteChart.js
@@ -107,7 +107,12 @@ export function WebsiteChart({
-
+
diff --git a/components/pages/reports/FunnelChart.js b/components/pages/reports/FunnelChart.js
new file mode 100644
index 00000000..44c99092
--- /dev/null
+++ b/components/pages/reports/FunnelChart.js
@@ -0,0 +1,43 @@
+import FunnelGraph from 'funnel-graph-js/dist/js/funnel-graph';
+import { useEffect, useRef } from 'react';
+
+export default function FunnelChart() {
+ const funnel = useRef(null);
+
+ useEffect(() => {
+ funnel.current.innerHTML = '';
+
+ const data = {
+ labels: ['Cv Sent', '1st Interview', '2nd Interview', '3rd Interview', 'Offer'],
+ subLabels: ['Cv Sent', '1st Interview', '2nd Interview', '3rd Interview', 'Offer'],
+ colors: [
+ ['#FFB178', '#FF78B1', '#FF3C8E'],
+ ['#FFB178', '#FF78B1', '#FF3C8E'],
+ ['#A0BBFF', '#EC77FF'],
+ ['#A0F9FF', '#7795FF'],
+ ['#FFB178', '#FF78B1', '#FF3C8E'],
+ ],
+ values: [[3500], [3300], [2000], [600], [330]],
+ };
+
+ const graph = new FunnelGraph({
+ container: '.funnel',
+ gradientDirection: 'horizontal',
+ data: data,
+ displayPercent: true,
+ direction: 'Vertical',
+ width: 1000,
+ height: 350,
+ subLabelValue: 'values',
+ });
+
+ graph.draw();
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/components/pages/reports/FunnelDetails.js b/components/pages/reports/FunnelDetails.js
new file mode 100644
index 00000000..e69de29b
diff --git a/components/pages/reports/FunnelForm.js b/components/pages/reports/FunnelForm.js
new file mode 100644
index 00000000..081105f1
--- /dev/null
+++ b/components/pages/reports/FunnelForm.js
@@ -0,0 +1,104 @@
+import { useMutation } from '@tanstack/react-query';
+import DateFilter from 'components/input/DateFilter';
+import WebsiteSelect from 'components/input/WebsiteSelect';
+import useApi from 'hooks/useApi';
+import useMessages from 'hooks/useMessages';
+import useUser from 'hooks/useUser';
+import { parseDateRange } from 'lib/date';
+import { useRouter } from 'next/router';
+import { useEffect, useState } from 'react';
+import {
+ Button,
+ Form,
+ FormButtons,
+ FormInput,
+ FormRow,
+ SubmitButton,
+ TextField,
+} from 'react-basics';
+import styles from './FunnelForm.module.css';
+import { getNextInternalQuery } from 'next/dist/server/request-meta';
+
+export function FunnelForm({ onSearch }) {
+ const { formatMessage, labels, getMessage } = useMessages();
+ const [dateRange, setDateRange] = useState(null);
+ const [startDate, setStartDate] = useState(null);
+ const [endDate, setEndDate] = useState(null);
+ const [urls, setUrls] = useState(['']);
+ const [websiteId, setWebsiteId] = useState('');
+
+ const handleSubmit = async data => {
+ onSearch(data);
+ };
+
+ const handleDateChange = value => {
+ const { startDate, endDate } = parseDateRange(value);
+
+ setDateRange(value);
+ setStartDate(startDate);
+ setEndDate(endDate);
+ };
+
+ const handleAddUrl = () => setUrls([...urls, 'meow']);
+
+ const handleRemoveUrl = i => setUrls(urls.splice(i, 1));
+
+ const handleUrlChange = (value, i) => {
+ const nextUrls = [...urls];
+
+ nextUrls[i] = value.target.value;
+ setUrls(nextUrls);
+ };
+
+ return (
+ <>
+
+ >
+ );
+}
+
+export default FunnelForm;
diff --git a/components/pages/reports/FunnelForm.module.css b/components/pages/reports/FunnelForm.module.css
new file mode 100644
index 00000000..9a8d924b
--- /dev/null
+++ b/components/pages/reports/FunnelForm.module.css
@@ -0,0 +1,19 @@
+.filter {
+ min-width: 200px;
+}
+
+.hiddenInput {
+ max-height: 100px;
+}
+
+.hiddenInput {
+ visibility: hidden;
+ min-height: 0px;
+ max-height: 0px;
+}
+
+.hidden {
+ visibility: hidden;
+ min-height: 0px;
+ max-height: 0px;
+}
diff --git a/components/pages/reports/FunnelPage.js b/components/pages/reports/FunnelPage.js
new file mode 100644
index 00000000..3cfa63a8
--- /dev/null
+++ b/components/pages/reports/FunnelPage.js
@@ -0,0 +1,26 @@
+import Page from 'components/layout/Page';
+import FunnelChart from './FunnelChart';
+import FunnelForm from './FunnelForm';
+
+export default function FunnelPage() {
+ function handleOnSearch() {
+ // do API CALL to api/reports/funnel to get funnelData
+ // Get DATA
+ }
+
+ return (
+
+ funnelPage
+ {/* */}
+ website / start/endDate urls: []
+
+ {/* {!chartLoaded && }
+ {chartLoaded && (
+ <>
+ {!view && }
+ {view && }
+ >
+ )} */}
+
+ );
+}
diff --git a/components/pages/settings/profile/DateRangeSetting.js b/components/pages/settings/profile/DateRangeSetting.js
index 152aba1d..202bbbe1 100644
--- a/components/pages/settings/profile/DateRangeSetting.js
+++ b/components/pages/settings/profile/DateRangeSetting.js
@@ -7,13 +7,15 @@ import useMessages from 'hooks/useMessages';
export function DateRangeSetting() {
const { formatMessage, labels } = useMessages();
const [dateRange, setDateRange] = useDateRange();
- const { startDate, endDate, value } = dateRange;
+ const { value } = dateRange;
- const handleReset = () => setDateRange(DEFAULT_DATE_RANGE);
+ const handleReset = () => {
+ setDateRange(DEFAULT_DATE_RANGE);
+ };
return (
-
+
);
diff --git a/db/postgresql/migrations/02_report_schema/migration.sql b/db/postgresql/migrations/02_report_schema/migration.sql
index 61f1164d..8b2bf0f5 100644
--- a/db/postgresql/migrations/02_report_schema/migration.sql
+++ b/db/postgresql/migrations/02_report_schema/migration.sql
@@ -1,18 +1,19 @@
-- CreateTable
-CREATE TABLE "report" (
+CREATE TABLE "user_report" (
"report_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
+ "website_id" UUID NOT NULL,
"report_name" VARCHAR(200) NOT NULL,
"template_name" VARCHAR(200) NOT NULL,
"parameters" VARCHAR(6000) NOT NULL,
"created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(6),
- CONSTRAINT "report_pkey" PRIMARY KEY ("report_id")
+ CONSTRAINT "user_report_pkey" PRIMARY KEY ("report_id")
);
-- CreateIndex
-CREATE UNIQUE INDEX "report_report_id_key" ON "report"("report_id");
+CREATE UNIQUE INDEX "user_report_report_id_key" ON "user_report"("report_id");
-- CreateIndex
-CREATE INDEX "report_user_id_idx" ON "report"("user_id");
+CREATE INDEX "user_report_user_id_idx" ON "user_report"("user_id");
diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma
index 7523ce21..ee5ff4b4 100644
--- a/db/postgresql/schema.prisma
+++ b/db/postgresql/schema.prisma
@@ -19,7 +19,7 @@ model User {
website Website[]
teamUser TeamUser[]
- ReportTemplate ReportTemplate[]
+ ReportTemplate UserReport[]
@@map("user")
}
@@ -156,9 +156,10 @@ model TeamWebsite {
@@map("team_website")
}
-model ReportTemplate {
+model UserReport {
id String @id() @unique() @map("report_id") @db.Uuid
userId String @map("user_id") @db.Uuid
+ websiteId String @map("website_id") @db.Uuid
reportName String @map("report_name") @db.VarChar(200)
templateName String @map("template_name") @db.VarChar(200)
parameters String @map("parameters") @db.VarChar(6000)
@@ -168,5 +169,5 @@ model ReportTemplate {
user User @relation(fields: [userId], references: [id])
@@index([userId])
- @@map("report")
+ @@map("user_report")
}
diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts
index 88922c0f..e97be806 100644
--- a/lib/clickhouse.ts
+++ b/lib/clickhouse.ts
@@ -129,8 +129,8 @@ function getFunnelQuery(urls: string[]): {
return urls.reduce(
(pv, cv, i) => {
pv.columnsQuery += `\n,url_path = {url${i}:String}${
- i > 0 && urls[i - 1] ? ` AND request_url = {url${i - 1}:String}` : ''
- },'`;
+ i > 0 && urls[i - 1] ? ` AND referrer_path = {url${i - 1}:String}` : ''
+ }`;
pv.conditionQuery += `${i > 0 ? ',' : ''} {url${i}:String}`;
pv.urlParams[`url${i}`] = cv;
@@ -150,7 +150,7 @@ function parseFilters(filters: WebsiteMetricFilter = {}, params: any = {}) {
};
}
-async function rawQuery(query, params = {}) {
+async function rawQuery(query, params = {}): Promise {
if (process.env.LOG_QUERY) {
log('QUERY:\n', query);
log('PARAMETERS:\n', params);
@@ -158,7 +158,7 @@ async function rawQuery(query, params = {}) {
await connect();
- return clickhouse.query(query, { params }).toPromise();
+ return clickhouse.query(query, { params }).toPromise() as Promise;
}
async function findUnique(data) {
diff --git a/lib/prisma.ts b/lib/prisma.ts
index ce2238b6..fdd8a58d 100644
--- a/lib/prisma.ts
+++ b/lib/prisma.ts
@@ -148,7 +148,8 @@ function getFunnelQuery(
const levelNumber = i + 1;
const start = i > 0 ? ',' : '';
- pv.levelQuery += `\n
+ if (levelNumber >= 2) {
+ pv.levelQuery += `\n
, level${levelNumber} AS (
select cl.*,
l0.created_at level_${levelNumber}_created_at,
@@ -156,13 +157,14 @@ function getFunnelQuery(
from level${i} cl
left join level0 l0
on cl.session_id = l0.session_id
- and l0.created_at between cl.level_${levelNumber}_created_at
- and ${getAddMinutesQuery(`cl.level_${levelNumber}_created_at`, windowMinutes)}
+ and l0.created_at between cl.level_${i}_created_at
+ and ${getAddMinutesQuery(`cl.level_${i}_created_at`, windowMinutes)}
and l0.referrer_path = $${i + initParamLength}
- and l0.url_path = $${i + initParamLength}
+ and l0.url_path = $${levelNumber + initParamLength}
)`;
+ }
- pv.sumQuery += `\n${start}SUM(CASE WHEN l1_url is not null THEN 1 ELSE 0 END) AS level1`;
+ pv.sumQuery += `\n${start}SUM(CASE WHEN level_${levelNumber}_url is not null THEN 1 ELSE 0 END) AS level${levelNumber}`;
pv.urlFilterQuery += `\n${start}$${levelNumber + initParamLength} `;
diff --git a/package.json b/package.json
index 0db07f49..7f365db1 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,7 @@
"dotenv": "^10.0.0",
"formik": "^2.2.9",
"fs-extra": "^10.0.1",
+ "funnel-graph-js": "^1.3.7",
"immer": "^9.0.12",
"ipaddr.js": "^2.0.1",
"is-ci": "^3.0.1",
diff --git a/pages/_app.js b/pages/_app.js
index 22458215..bc55355b 100644
--- a/pages/_app.js
+++ b/pages/_app.js
@@ -9,6 +9,7 @@ import useConfig from 'hooks/useConfig';
import '@fontsource/inter/400.css';
import '@fontsource/inter/700.css';
import 'react-basics/dist/styles.css';
+import 'styles/funnelChart.css';
import 'styles/variables.css';
import 'styles/locale.css';
import 'styles/index.css';
diff --git a/pages/api/reports/funnel.ts b/pages/api/reports/funnel.ts
index 080130de..ee450eb6 100644
--- a/pages/api/reports/funnel.ts
+++ b/pages/api/reports/funnel.ts
@@ -36,14 +36,14 @@ export default async (
const startDate = new Date(+startAt);
const endDate = new Date(+endAt);
- const data = getPageviewFunnel(websiteId, {
+ const data = await getPageviewFunnel(websiteId, {
startDate,
endDate,
urls,
windowMinutes: window,
});
- return ok(res);
+ return ok(res, data);
}
return methodNotAllowed(res);
diff --git a/pages/reports/funnel.js b/pages/reports/funnel.js
new file mode 100644
index 00000000..d4bf7dd2
--- /dev/null
+++ b/pages/reports/funnel.js
@@ -0,0 +1,22 @@
+import { useRouter } from 'next/router';
+import AppLayout from 'components/layout/AppLayout';
+import FunnelPage from 'components/pages/reports/FunnelPage';
+import useMessages from 'hooks/useMessages';
+
+export default function DetailsPage() {
+ // const { formatMessage, labels } = useMessages();
+ // const router = useRouter();
+ // const { id } = router.query;
+
+ // if (!id) {
+ // return null;
+ // }
+
+ // return {/* */};
+
+ return (
+
+
+
+ );
+}
diff --git a/queries/analytics/pageview/getPageviewFunnel.ts b/queries/analytics/pageview/getPageviewFunnel.ts
index f7751630..d80a681c 100644
--- a/queries/analytics/pageview/getPageviewFunnel.ts
+++ b/queries/analytics/pageview/getPageviewFunnel.ts
@@ -27,7 +27,13 @@ async function relationalQuery(
endDate: Date;
urls: string[];
},
-) {
+): Promise<
+ {
+ level: number;
+ url: string;
+ count: any;
+ }[]
+> {
const { windowMinutes, startDate, endDate, urls } = criteria;
const { rawQuery, getFunnelQuery, toUuid } = prisma;
const { levelQuery, sumQuery, urlFilterQuery } = getFunnelQuery(urls, windowMinutes);
@@ -36,21 +42,24 @@ async function relationalQuery(
return rawQuery(
`WITH level0 AS (
- select session_id, url_path, created_at
- from website_event
- where url_path in (${urlFilterQuery})
- website_event.website_id = $1${toUuid()}
- and created_at between $2 and $3
- ),level1 AS (
- select session_id, url_path as level1_url, created_at as level1_created_at
- from level0
- )${levelQuery}
-
- SELECT ${sumQuery}
- from level3;
- `,
+ select session_id, url_path, referrer_path, created_at
+ from website_event
+ where url_path in (${urlFilterQuery})
+ and website_id = $1${toUuid()}
+ and created_at between $2 and $3
+ ),level1 AS (
+ select session_id, url_path as level_1_url, created_at as level_1_created_at
+ from level0
+ where url_path = $4
+ )${levelQuery}
+
+ SELECT ${sumQuery}
+ from level3;
+ `,
params,
- );
+ ).then((a: { [key: string]: number }) => {
+ return urls.map((b, i) => ({ level: i + 1, url: b, count: a[`level${i + 1}`] || 0 }));
+ });
}
async function clickhouseQuery(
@@ -61,7 +70,13 @@ async function clickhouseQuery(
endDate: Date;
urls: string[];
},
-) {
+): Promise<
+ {
+ level: number;
+ url: string;
+ count: any;
+ }[]
+> {
const { windowMinutes, startDate, endDate, urls } = criteria;
const { rawQuery, getBetweenDates, getFunnelQuery } = clickhouse;
const { columnsQuery, conditionQuery, urlParams } = getFunnelQuery(urls);
@@ -72,7 +87,7 @@ async function clickhouseQuery(
...urlParams,
};
- return rawQuery(
+ return rawQuery<{ level: number; count: number }[]>(
`
SELECT level,
count(*) AS count
@@ -80,7 +95,7 @@ async function clickhouseQuery(
SELECT session_id,
windowFunnel({window:UInt32}, 'strict_order')
(
- created_at,
+ created_at
${columnsQuery}
) AS level
FROM website_event
@@ -93,5 +108,9 @@ async function clickhouseQuery(
ORDER BY level ASC;
`,
params,
- );
+ ).then(a => {
+ return a
+ .filter(b => b.level !== 0)
+ .map((c, i) => ({ level: c.level, url: urls[i], count: c.count }));
+ });
}
diff --git a/styles/funnelChart.css b/styles/funnelChart.css
new file mode 100644
index 00000000..c72d42e7
--- /dev/null
+++ b/styles/funnelChart.css
@@ -0,0 +1,148 @@
+.svg-funnel-js {
+ display: inline-block;
+ position: relative;
+}
+.svg-funnel-js svg {
+ display: block;
+}
+.svg-funnel-js .svg-funnel-js__labels {
+ position: absolute;
+ display: flex;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+}
+.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__labels {
+ flex-direction: column;
+}
+
+.svg-funnel-js body {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.svg-funnel-js {
+ font-family: 'Open Sans', sans-serif;
+}
+.svg-funnel-js .svg-funnel-js__container {
+ width: 100%;
+ height: 100%;
+}
+.svg-funnel-js .svg-funnel-js__labels {
+ width: 100%;
+ box-sizing: border-box;
+}
+.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label {
+ flex: 1 1 0;
+ position: relative;
+}
+.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__value {
+ font-size: 24px;
+ color: #fff;
+ line-height: 18px;
+ margin-bottom: 6px;
+}
+.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__title {
+ font-size: 12px;
+ font-weight: bold;
+ color: #21ffa2;
+}
+.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__percentage {
+ font-size: 16px;
+ font-weight: bold;
+ color: #9896dc;
+}
+.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 100%;
+ left: 0;
+ padding: 8px 24px;
+ box-sizing: border-box;
+ background-color: rgba(8, 7, 48, 0.8);
+ margin-top: 24px;
+ opacity: 0;
+ transition: opacity 0.1s ease;
+ cursor: default;
+}
+.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul {
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+}
+.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul li {
+ font-size: 13px;
+ line-height: 16px;
+ color: #fff;
+ margin: 18px 0;
+}
+.svg-funnel-js
+ .svg-funnel-js__labels
+ .svg-funnel-js__label
+ .label__segment-percentages
+ ul
+ li
+ .percentage__list-label {
+ font-weight: bold;
+ color: #05df9d;
+}
+.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label:hover .label__segment-percentages {
+ opacity: 1;
+}
+.svg-funnel-js:not(.svg-funnel-js--vertical) {
+ padding-top: 64px;
+ padding-bottom: 16px;
+}
+.svg-funnel-js:not(.svg-funnel-js--vertical) .svg-funnel-js__label {
+ padding-left: 24px;
+}
+.svg-funnel-js:not(.svg-funnel-js--vertical) .svg-funnel-js__label:not(:first-child) {
+ border-left: 1px solid #9896dc;
+}
+.svg-funnel-js.svg-funnel-js--vertical {
+ padding-left: 120px;
+ padding-right: 16px;
+}
+.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label {
+ padding-top: 24px;
+}
+.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label:not(:first-child) {
+ border-top: 1px solid #9896dc;
+}
+.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label .label__segment-percentages {
+ margin-top: 0;
+ margin-left: 106px;
+ width: calc(100% - 106px);
+}
+.svg-funnel-js.svg-funnel-js--vertical
+ .svg-funnel-js__label
+ .label__segment-percentages
+ .segment-percentage__list {
+ display: flex;
+ justify-content: space-around;
+}
+.svg-funnel-js .svg-funnel-js__subLabels {
+ display: flex;
+ justify-content: center;
+ margin-top: 24px;
+ position: absolute;
+ width: 100%;
+ left: 0;
+}
+.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel {
+ display: flex;
+ font-size: 12px;
+ color: #fff;
+ line-height: 16px;
+}
+.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel:not(:first-child) {
+ margin-left: 16px;
+}
+.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel .svg-funnel-js__subLabel--color {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ margin: 2px 8px 2px 0;
+}
diff --git a/yarn.lock b/yarn.lock
index 41cca434..1f9ea77a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5500,6 +5500,11 @@ functions-have-names@^1.2.2:
resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
+funnel-graph-js@^1.3.7:
+ version "1.4.2"
+ resolved "https://registry.yarnpkg.com/funnel-graph-js/-/funnel-graph-js-1.4.2.tgz#b82150189e8afa59104d881d5dcf55a28d715342"
+ integrity sha512-9bnmcBve7RDH9dTF9BLuUpuisKkDka3yrfhs+Z/106ZgJvqIse1RfKQWjW+QdAlTrZqC9oafen7t/KuJKv9ohA==
+
generic-names@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-4.0.0.tgz#0bd8a2fd23fe8ea16cbd0a279acd69c06933d9a3"