a looooot

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-22 21:50:30 +01:00
parent 1d800835b8
commit 9c92803c4c
61 changed files with 2689 additions and 681 deletions

View File

@@ -45,6 +45,7 @@
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "^18.16.0",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",

View File

@@ -0,0 +1,62 @@
import { Logo } from '@/components/Logo';
import Image from 'next/image';
import Link from 'next/link';
import { Heading2, Lead2 } from './copy';
import { JoinWaitlist } from './join-waitlist';
export default function Footer() {
return (
<footer className="bg-blue-darker text-white relative mt-40 relative">
<div className="inset-0 absolute h-full w-full bg-[radial-gradient(circle,rgba(255,255,255,0.2)_0%,rgba(255,255,255,0)_100%)]"></div>
<div className="relative container flex flex-col items-center text-center">
<div className="my-24">
<Heading2 className="text-white mb-2">Get early access</Heading2>
<Lead2>Ready to set your analytics free? Get on our waitlist.</Lead2>
<div className="mt-8">
<JoinWaitlist className="text-white bg-white/20 border-white/30 focus:ring-white" />
</div>
</div>
<div className="overflow-hidden rounded-xl">
<div className="p-3 bg-white/20">
<Image
src="/demo/overview-min.png"
width={1080}
height={608}
alt="Openpanel overview page"
className="w-full rounded-lg"
/>
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0">
<div className="h-px w-full bg-[radial-gradient(circle,rgba(255,255,255,0.7)_0%,rgba(255,255,255,0.7)_50%,rgba(255,255,255,0)_100%)]"></div>
<div className="p-4 bg-blue-darker">
<div className="container">
<div className="flex justify-between items-center text-sm">
<Logo />
<div className="flex gap-4">
<Link className="hover:underline" href="/terms">
Terms and Conditions
</Link>
<Link className="hover:underline" href="/privacy">
Privacy Policy
</Link>
<a
className="hover:underline"
href="https://twitter.com/CarlLindesvard"
target="_blank"
rel="nofollow"
>
Follow on X
</a>
</div>
</div>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -38,41 +38,27 @@ const features = [
export function Hero({ waitlistCount }: { waitlistCount: number }) {
return (
<div>
<div className="absolute top-0 left-0 right-0 py-6">
<div className="container">
<div className="flex justify-between">
<Logo />
</div>
</div>
</div>
<div
className="flex flex-col items-center w-full text-center text-blue-950 bg-[radial-gradient(circle_at_2px_2px,#D9DEF6_2px,transparent_0)] relative"
style={{
backgroundSize: '70px 70px',
}}
>
<div className="py-32 p-4 flex flex-col items-center max-w-3xl bg-[radial-gradient(circle,rgba(255,255,255,0.7)_0%,rgba(255,255,255,0.7)_50%,rgba(255,255,255,0)_100%)]">
<Heading1 className="mb-4">
An open-source
<br />
alternative to Mixpanel
</Heading1>
<p className="mb-8">
Mixpanel + Plausible ={' '}
<strong className="text-blue-600">Openpanel!</strong> A simple
analytics tool that your wallet can afford.
<div className="flex flex-col items-center w-full text-center text-blue-950">
<div className="py-32 p-4 flex flex-col items-center max-w-3xl bg-[radial-gradient(circle,rgba(255,255,255,0.7)_0%,rgba(255,255,255,0.7)_50%,rgba(255,255,255,0)_100%)]">
<Heading1 className="mb-4">
An open-source
<br />
alternative to Mixpanel
</Heading1>
<p className="mb-8">
Mixpanel + Plausible ={' '}
<strong className="text-blue-600">Openpanel!</strong> A simple
analytics tool that your wallet can afford.
</p>
<JoinWaitlist />
<div className="mt-4 text-sm">
<p>Get ahead of the curve and join our waiting list{' - '}</p>
<p>
there are already{' '}
<strong>{waitlistCount} savvy individuals on board!</strong> 🎉
</p>
<JoinWaitlist />
<div className="mt-4 text-sm">
<p>Get ahead of the curve and join our waiting list{' - '}</p>
<p>
there are already{' '}
<strong>{waitlistCount} savvy individuals on board!</strong> 🎉
</p>
</div>
{/* <div className="flex flex-wrap gap-10 mt-8 max-w-xl justify-center">
</div>
{/* <div className="flex flex-wrap gap-10 mt-8 max-w-xl justify-center">
{features.map(({ icon: Icon, title }) => (
<div className="flex gap-2 items-center justify-center">
<Icon className="text-blue-light " />
@@ -80,7 +66,6 @@ export function Hero({ waitlistCount }: { waitlistCount: number }) {
</div>
))}
</div> */}
</div>
</div>
</div>
);

View File

@@ -2,10 +2,12 @@ import { cn } from '@/utils/cn';
import '@/styles/globals.css';
import { Logo } from '@/components/Logo';
import type { Metadata } from 'next';
import { Bricolage_Grotesque } from 'next/font/google';
import Script from 'next/script';
import Footer from './footer';
import { defaultMeta } from './meta';
export const metadata: Metadata = {
@@ -34,7 +36,21 @@ export default function RootLayout({
font.className
)}
>
{children}
<div className="absolute top-0 left-0 right-0 py-6 z-10">
<div className="container">
<div className="flex justify-between">
<Logo />
</div>
</div>
</div>
<div
className="w-full h-screen text-blue-950 bg-[radial-gradient(circle_at_2px_2px,#D9DEF6_2px,transparent_0)] absolute top-0 left-0 right-0 z-0"
style={{
backgroundSize: '70px 70px',
}}
/>
<div className="relative">{children}</div>
<Footer />
</body>
<Script
src="/op.js"

View File

@@ -1,12 +1,8 @@
import { Logo } from '@/components/Logo';
import Image from 'next/image';
import { db } from '@mixan/db';
import { PreviewCarousel } from './carousel';
import { Heading2, Lead2, Paragraph } from './copy';
import { Hero } from './hero';
import { JoinWaitlist } from './join-waitlist';
import { Sections } from './section';
export const dynamic = 'force-dynamic';
@@ -111,52 +107,6 @@ export default async function Page() {
</div>
</div>
</div>
<footer className="bg-blue-darker text-white relative mt-40 relative">
<div className="inset-0 absolute h-full w-full bg-[radial-gradient(circle,rgba(255,255,255,0.2)_0%,rgba(255,255,255,0)_100%)]"></div>
<div className="relative container flex flex-col items-center text-center">
<div className="my-24">
<Heading2 className="text-white mb-2">Get early access</Heading2>
<Lead2>
Ready to set your analytics free? Get on our waitlist.
</Lead2>
<div className="mt-8">
<JoinWaitlist className="text-white bg-white/20 border-white/30 focus:ring-white" />
</div>
</div>
<div className="overflow-hidden rounded-xl">
<div className="p-3 bg-white/20">
<Image
src="/demo/overview-min.png"
width={1080}
height={608}
alt="Openpanel overview page"
className="w-full rounded-lg"
/>
</div>
</div>
</div>
<div className="absolute bottom-0 left-0 right-0">
<div className="h-px w-full bg-[radial-gradient(circle,rgba(255,255,255,0.7)_0%,rgba(255,255,255,0.7)_50%,rgba(255,255,255,0)_100%)]"></div>
<div className="p-4 bg-blue-darker">
<div className="container">
<div className="flex justify-between items-center text-sm">
<Logo />
<a
className="hover:underline"
href="https://twitter.com/CarlLindesvard"
target="_blank"
rel="nofollow"
>
Follow on X
</a>
</div>
</div>
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,524 @@
import { Heading1 } from '../copy';
export const dynamic = 'force-dynamic';
export const revalidate = 3600;
export default function Page() {
return (
<div className="container mt-[150px]">
<article className="prose">
<Heading1>Privacy Policy</Heading1>
<p>Last updated: February 22, 2024</p>
<p>
This Privacy Policy describes Our policies and procedures on the
collection, use and disclosure of Your information when You use the
Service and tells You about Your privacy rights and how the law
protects You.
</p>
<p>
We use Your Personal data to provide and improve the Service. By using
the Service, You agree to the collection and use of information in
accordance with this Privacy Policy.
</p>
<h2>Interpretation and Definitions</h2>
<h3>Interpretation</h3>
<p>
The words of which the initial letter is capitalized have meanings
defined under the following conditions. The following definitions
shall have the same meaning regardless of whether they appear in
singular or in plural.
</p>
<h3>Definitions</h3>
<p>For the purposes of this Privacy Policy:</p>
<ul>
<li>
<p>
<strong>Account</strong> means a unique account created for You to
access our Service or parts of our Service.
</p>
</li>
<li>
<p>
<strong>Affiliate</strong> means an entity that controls, is
controlled by or is under common control with a party, where
"control" means ownership of 50% or more of the shares, equity
interest or other securities entitled to vote for election of
directors or other managing authority.
</p>
</li>
<li>
<p>
<strong>Application</strong> refers to Openpanel, the software
program provided by the Company.
</p>
</li>
<li>
<p>
<strong>Company</strong> (referred to as either "the Company",
"We", "Us" or "Our" in this Agreement) refers to Coderax AB, Sankt
Eriksgatan 100, 113 31, Stockholm.
</p>
</li>
<li>
<p>
<strong>Cookies</strong> are small files that are placed on Your
computer, mobile device or any other device by a website,
containing the details of Your browsing history on that website
among its many uses.
</p>
</li>
<li>
<p>
<strong>Country</strong> refers to: Sweden
</p>
</li>
<li>
<p>
<strong>Device</strong> means any device that can access the
Service such as a computer, a cellphone or a digital tablet.
</p>
</li>
<li>
<p>
<strong>Personal Data</strong> is any information that relates to
an identified or identifiable individual.
</p>
</li>
<li>
<p>
<strong>Service</strong> refers to the Application or the Website
or both.
</p>
</li>
<li>
<p>
<strong>Service Provider</strong> means any natural or legal
person who processes the data on behalf of the Company. It refers
to third-party companies or individuals employed by the Company to
facilitate the Service, to provide the Service on behalf of the
Company, to perform services related to the Service or to assist
the Company in analyzing how the Service is used.
</p>
</li>
<li>
<p>
<strong>Usage Data</strong> refers to data collected
automatically, either generated by the use of the Service or from
the Service infrastructure itself (for example, the duration of a
page visit).
</p>
</li>
<li>
<p>
<strong>Website</strong> refers to Openpanel, accessible from{' '}
<a
href="https://openpanel.com"
rel="external nofollow noopener"
target="_blank"
>
https://openpanel.com
</a>
</p>
</li>
<li>
<p>
<strong>You</strong> means the individual accessing or using the
Service, or the company, or other legal entity on behalf of which
such individual is accessing or using the Service, as applicable.
</p>
</li>
</ul>
<h2>Collecting and Using Your Personal Data</h2>
<h3>Types of Data Collected</h3>
<h4>Personal Data</h4>
<p>
While using Our Service, We may ask You to provide Us with certain
personally identifiable information that can be used to contact or
identify You. Personally identifiable information may include, but is
not limited to:
</p>
<ul>
<li>
<p>Email address</p>
</li>
<li>
<p>First name and last name</p>
</li>
<li>
<p>Usage Data</p>
</li>
</ul>
<h4>Usage Data</h4>
<p>Usage Data is collected automatically when using the Service.</p>
<p>
Usage Data may include information such as Your Device's Internet
Protocol address (e.g. IP address), browser type, browser version, the
pages of our Service that You visit, the time and date of Your visit,
the time spent on those pages, unique device identifiers and other
diagnostic data.
</p>
<p>
When You access the Service by or through a mobile device, We may
collect certain information automatically, including, but not limited
to, the type of mobile device You use, Your mobile device unique ID,
the IP address of Your mobile device, Your mobile operating system,
the type of mobile Internet browser You use, unique device identifiers
and other diagnostic data.
</p>
<p>
We may also collect information that Your browser sends whenever You
visit our Service or when You access the Service by or through a
mobile device.
</p>
<h4>Tracking Technologies and Cookies</h4>
<p>
We use Cookies and similar tracking technologies to track the activity
on Our Service and store certain information. Tracking technologies
used are beacons, tags, and scripts to collect and track information
and to improve and analyze Our Service. The technologies We use may
include:
</p>
<ul>
<li>
<strong>Cookies or Browser Cookies.</strong> A cookie is a small
file placed on Your Device. You can instruct Your browser to refuse
all Cookies or to indicate when a Cookie is being sent. However, if
You do not accept Cookies, You may not be able to use some parts of
our Service. Unless you have adjusted Your browser setting so that
it will refuse Cookies, our Service may use Cookies.
</li>
<li>
<strong>Web Beacons.</strong> Certain sections of our Service and
our emails may contain small electronic files known as web beacons
(also referred to as clear gifs, pixel tags, and single-pixel gifs)
that permit the Company, for example, to count users who have
visited those pages or opened an email and for other related website
statistics (for example, recording the popularity of a certain
section and verifying system and server integrity).
</li>
</ul>
<p>
Cookies can be "Persistent" or "Session" Cookies. Persistent Cookies
remain on Your personal computer or mobile device when You go offline,
while Session Cookies are deleted as soon as You close Your web
browser. You can learn more about cookies{' '}
<a
href="https://www.termsfeed.com/blog/cookies/#What_Are_Cookies"
target="_blank"
rel="nofollow"
>
here
</a>
.
</p>
<p>
We use both Session and Persistent Cookies for the purposes set out
below:
</p>
<ul>
<li>
<p>
<strong>Necessary / Essential Cookies</strong>
</p>
<p>Type: Session Cookies</p>
<p>Administered by: Us</p>
<p>
Purpose: These Cookies are essential to provide You with services
available through the Website and to enable You to use some of its
features. They help to authenticate users and prevent fraudulent
use of user accounts. Without these Cookies, the services that You
have asked for cannot be provided, and We only use these Cookies
to provide You with those services.
</p>
</li>
<li>
<p>
<strong>Cookies Policy / Notice Acceptance Cookies</strong>
</p>
<p>Type: Persistent Cookies</p>
<p>Administered by: Us</p>
<p>
Purpose: These Cookies identify if users have accepted the use of
cookies on the Website.
</p>
</li>
<li>
<p>
<strong>Functionality Cookies</strong>
</p>
<p>Type: Persistent Cookies</p>
<p>Administered by: Us</p>
<p>
Purpose: These Cookies allow us to remember choices You make when
You use the Website, such as remembering your login details or
language preference. The purpose of these Cookies is to provide
You with a more personal experience and to avoid You having to
re-enter your preferences every time You use the Website.
</p>
</li>
</ul>
<p>
For more information about the cookies we use and your choices
regarding cookies, please visit our Cookies Policy or the Cookies
section of our Privacy Policy.
</p>
<h3>Use of Your Personal Data</h3>
<p>The Company may use Personal Data for the following purposes:</p>
<ul>
<li>
<p>
<strong>To provide and maintain our Service</strong>, including to
monitor the usage of our Service.
</p>
</li>
<li>
<p>
<strong>To manage Your Account:</strong> to manage Your
registration as a user of the Service. The Personal Data You
provide can give You access to different functionalities of the
Service that are available to You as a registered user.
</p>
</li>
<li>
<p>
<strong>For the performance of a contract:</strong> the
development, compliance and undertaking of the purchase contract
for the products, items or services You have purchased or of any
other contract with Us through the Service.
</p>
</li>
<li>
<p>
<strong>To contact You:</strong> To contact You by email,
telephone calls, SMS, or other equivalent forms of electronic
communication, such as a mobile application's push notifications
regarding updates or informative communications related to the
functionalities, products or contracted services, including the
security updates, when necessary or reasonable for their
implementation.
</p>
</li>
<li>
<p>
<strong>To provide You</strong> with news, special offers and
general information about other goods, services and events which
we offer that are similar to those that you have already purchased
or enquired about unless You have opted not to receive such
information.
</p>
</li>
<li>
<p>
<strong>To manage Your requests:</strong> To attend and manage
Your requests to Us.
</p>
</li>
<li>
<p>
<strong>For business transfers:</strong> We may use Your
information to evaluate or conduct a merger, divestiture,
restructuring, reorganization, dissolution, or other sale or
transfer of some or all of Our assets, whether as a going concern
or as part of bankruptcy, liquidation, or similar proceeding, in
which Personal Data held by Us about our Service users is among
the assets transferred.
</p>
</li>
<li>
<p>
<strong>For other purposes</strong>: We may use Your information
for other purposes, such as data analysis, identifying usage
trends, determining the effectiveness of our promotional campaigns
and to evaluate and improve our Service, products, services,
marketing and your experience.
</p>
</li>
</ul>
<p>
We may share Your personal information in the following situations:
</p>
<ul>
<li>
<strong>With Service Providers:</strong> We may share Your personal
information with Service Providers to monitor and analyze the use of
our Service, to contact You.
</li>
<li>
<strong>For business transfers:</strong> We may share or transfer
Your personal information in connection with, or during negotiations
of, any merger, sale of Company assets, financing, or acquisition of
all or a portion of Our business to another company.
</li>
<li>
<strong>With Affiliates:</strong> We may share Your information with
Our affiliates, in which case we will require those affiliates to
honor this Privacy Policy. Affiliates include Our parent company and
any other subsidiaries, joint venture partners or other companies
that We control or that are under common control with Us.
</li>
<li>
<strong>With business partners:</strong> We may share Your
information with Our business partners to offer You certain
products, services or promotions.
</li>
<li>
<strong>With other users:</strong> when You share personal
information or otherwise interact in the public areas with other
users, such information may be viewed by all users and may be
publicly distributed outside.
</li>
<li>
<strong>With Your consent</strong>: We may disclose Your personal
information for any other purpose with Your consent.
</li>
</ul>
<h3>Retention of Your Personal Data</h3>
<p>
The Company will retain Your Personal Data only for as long as is
necessary for the purposes set out in this Privacy Policy. We will
retain and use Your Personal Data to the extent necessary to comply
with our legal obligations (for example, if we are required to retain
your data to comply with applicable laws), resolve disputes, and
enforce our legal agreements and policies.
</p>
<p>
The Company will also retain Usage Data for internal analysis
purposes. Usage Data is generally retained for a shorter period of
time, except when this data is used to strengthen the security or to
improve the functionality of Our Service, or We are legally obligated
to retain this data for longer time periods.
</p>
<h3>Transfer of Your Personal Data</h3>
<p>
Your information, including Personal Data, is processed at the
Company's operating offices and in any other places where the parties
involved in the processing are located. It means that this information
may be transferred to — and maintained on — computers located outside
of Your state, province, country or other governmental jurisdiction
where the data protection laws may differ than those from Your
jurisdiction.
</p>
<p>
Your consent to this Privacy Policy followed by Your submission of
such information represents Your agreement to that transfer.
</p>
<p>
The Company will take all steps reasonably necessary to ensure that
Your data is treated securely and in accordance with this Privacy
Policy and no transfer of Your Personal Data will take place to an
organization or a country unless there are adequate controls in place
including the security of Your data and other personal information.
</p>
<h3>Delete Your Personal Data</h3>
<p>
You have the right to delete or request that We assist in deleting the
Personal Data that We have collected about You.
</p>
<p>
Our Service may give You the ability to delete certain information
about You from within the Service.
</p>
<p>
You may update, amend, or delete Your information at any time by
signing in to Your Account, if you have one, and visiting the account
settings section that allows you to manage Your personal information.
You may also contact Us to request access to, correct, or delete any
personal information that You have provided to Us.
</p>
<p>
Please note, however, that We may need to retain certain information
when we have a legal obligation or lawful basis to do so.
</p>
<h3>Disclosure of Your Personal Data</h3>
<h4>Business Transactions</h4>
<p>
If the Company is involved in a merger, acquisition or asset sale,
Your Personal Data may be transferred. We will provide notice before
Your Personal Data is transferred and becomes subject to a different
Privacy Policy.
</p>
<h4>Law enforcement</h4>
<p>
Under certain circumstances, the Company may be required to disclose
Your Personal Data if required to do so by law or in response to valid
requests by public authorities (e.g. a court or a government agency).
</p>
<h4>Other legal requirements</h4>
<p>
The Company may disclose Your Personal Data in the good faith belief
that such action is necessary to:
</p>
<ul>
<li>Comply with a legal obligation</li>
<li>Protect and defend the rights or property of the Company</li>
<li>
Prevent or investigate possible wrongdoing in connection with the
Service
</li>
<li>
Protect the personal safety of Users of the Service or the public
</li>
<li>Protect against legal liability</li>
</ul>
<h3>Security of Your Personal Data</h3>
<p>
The security of Your Personal Data is important to Us, but remember
that no method of transmission over the Internet, or method of
electronic storage is 100% secure. While We strive to use commercially
acceptable means to protect Your Personal Data, We cannot guarantee
its absolute security.
</p>
<h2>Children's Privacy</h2>
<p>
Our Service does not address anyone under the age of 13. We do not
knowingly collect personally identifiable information from anyone
under the age of 13. If You are a parent or guardian and You are aware
that Your child has provided Us with Personal Data, please contact Us.
If We become aware that We have collected Personal Data from anyone
under the age of 13 without verification of parental consent, We take
steps to remove that information from Our servers.
</p>
<p>
If We need to rely on consent as a legal basis for processing Your
information and Your country requires consent from a parent, We may
require Your parent's consent before We collect and use that
information.
</p>
<h2>Links to Other Websites</h2>
<p>
Our Service may contain links to other websites that are not operated
by Us. If You click on a third party link, You will be directed to
that third party's site. We strongly advise You to review the Privacy
Policy of every site You visit.
</p>
<p>
We have no control over and assume no responsibility for the content,
privacy policies or practices of any third party sites or services.
</p>
<h2>Changes to this Privacy Policy</h2>
<p>
We may update Our Privacy Policy from time to time. We will notify You
of any changes by posting the new Privacy Policy on this page.
</p>
<p>
We will let You know via email and/or a prominent notice on Our
Service, prior to the change becoming effective and update the "Last
updated" date at the top of this Privacy Policy.
</p>
<p>
You are advised to review this Privacy Policy periodically for any
changes. Changes to this Privacy Policy are effective when they are
posted on this page.
</p>
<h2>Contact Us</h2>
<p>
If you have any questions about this Privacy Policy, You can contact
us:
</p>
<ul>
<li>By email: hello@openpanel.dev</li>
</ul>
</article>
</div>
);
}

View File

@@ -0,0 +1,458 @@
import { Heading1 } from '../copy';
export const dynamic = 'force-dynamic';
export const revalidate = 3600;
export default function Page() {
return (
<div className="container mt-[150px]">
<article className="prose">
<Heading1>Terms and Conditions</Heading1>
<p>Last updated: February 22, 2024</p>
<p>
Please read these terms and conditions carefully before using Our
Service.
</p>
<h2>Interpretation and Definitions</h2>
<h3>Interpretation</h3>
<p>
The words of which the initial letter is capitalized have meanings
defined under the following conditions. The following definitions
shall have the same meaning regardless of whether they appear in
singular or in plural.
</p>
<h3>Definitions</h3>
<p>For the purposes of these Terms and Conditions:</p>
<ul>
<li>
<p>
<strong>Application</strong> means the software program provided
by the Company downloaded by You on any electronic device, named
Openpanel
</p>
</li>
<li>
<p>
<strong>Application Store</strong> means the digital distribution
service operated and developed by Apple Inc. (Apple App Store) or
Google Inc. (Google Play Store) in which the Application has been
downloaded.
</p>
</li>
<li>
<p>
<strong>Affiliate</strong> means an entity that controls, is
controlled by or is under common control with a party, where
&quot;control&quot; means ownership of 50% or more of the shares,
equity interest or other securities entitled to vote for election
of directors or other managing authority.
</p>
</li>
<li>
<p>
<strong>Account</strong> means a unique account created for You to
access our Service or parts of our Service.
</p>
</li>
<li>
<p>
<strong>Country</strong> refers to: Sweden
</p>
</li>
<li>
<p>
<strong>Company</strong> (referred to as either &quot;the
Company&quot;, &quot;We&quot;, &quot;Us&quot; or &quot;Our&quot;
in this Agreement) refers to Coderax AB, Sankt Eriksgatan 100, 113
31, Stockholm.
</p>
</li>
<li>
<p>
<strong>Device</strong> means any device that can access the
Service such as a computer, a cellphone or a digital tablet.
</p>
</li>
<li>
<p>
<strong>Free Trial</strong> refers to a limited period of time
that may be free when purchasing a Subscription.
</p>
</li>
<li>
<p>
<strong>Service</strong> refers to the Application or the Website
or both.
</p>
</li>
<li>
<p>
<strong>Subscriptions</strong> refer to the services or access to
the Service offered on a subscription basis by the Company to You.
</p>
</li>
<li>
<p>
<strong>Terms and Conditions</strong> (also referred as
&quot;Terms&quot;) mean these Terms and Conditions that form the
entire agreement between You and the Company regarding the use of
the Service.
</p>
</li>
<li>
<p>
<strong>Third-party Social Media Service</strong> means any
services or content (including data, information, products or
services) provided by a third-party that may be displayed,
included or made available by the Service.
</p>
</li>
<li>
<p>
<strong>Website</strong> refers to Openpanel, accessible from{' '}
<a
href="https://openpanel.dev"
rel="external nofollow noopener"
target="_blank"
>
https://openpanel.dev
</a>
</p>
</li>
<li>
<p>
<strong>You</strong> means the individual accessing or using the
Service, or the company, or other legal entity on behalf of which
such individual is accessing or using the Service, as applicable.
</p>
</li>
</ul>
<h2>Acknowledgment</h2>
<p>
These are the Terms and Conditions governing the use of this Service
and the agreement that operates between You and the Company. These
Terms and Conditions set out the rights and obligations of all users
regarding the use of the Service.
</p>
<p>
Your access to and use of the Service is conditioned on Your
acceptance of and compliance with these Terms and Conditions. These
Terms and Conditions apply to all visitors, users and others who
access or use the Service.
</p>
<p>
By accessing or using the Service You agree to be bound by these Terms
and Conditions. If You disagree with any part of these Terms and
Conditions then You may not access the Service.
</p>
<p>
You represent that you are over the age of 18. The Company does not
permit those under 18 to use the Service.
</p>
<p>
Your access to and use of the Service is also conditioned on Your
acceptance of and compliance with the Privacy Policy of the Company.
Our Privacy Policy describes Our policies and procedures on the
collection, use and disclosure of Your personal information when You
use the Application or the Website and tells You about Your privacy
rights and how the law protects You. Please read Our Privacy Policy
carefully before using Our Service.
</p>
<h2>Subscriptions</h2>
<h3>Subscription period</h3>
<p>
The Service or some parts of the Service are available only with a
paid Subscription. You will be billed in advance on a recurring and
periodic basis (such as daily, weekly, monthly or annually), depending
on the type of Subscription plan you select when purchasing the
Subscription.
</p>
<p>
At the end of each period, Your Subscription will automatically renew
under the exact same conditions unless You cancel it or the Company
cancels it.
</p>
<h3>Subscription cancellations</h3>
<p>
You may cancel Your Subscription renewal either through Your Account
settings page or by contacting the Company. You will not receive a
refund for the fees You already paid for Your current Subscription
period and You will be able to access the Service until the end of
Your current Subscription period.
</p>
<h3>Billing</h3>
<p>
You shall provide the Company with accurate and complete billing
information including full name, address, state, zip code, telephone
number, and a valid payment method information.
</p>
<p>
Should automatic billing fail to occur for any reason, the Company
will issue an electronic invoice indicating that you must proceed
manually, within a certain deadline date, with the full payment
corresponding to the billing period as indicated on the invoice.
</p>
<h3>Fee Changes</h3>
<p>
The Company, in its sole discretion and at any time, may modify the
Subscription fees. Any Subscription fee change will become effective
at the end of the then-current Subscription period.
</p>
<p>
The Company will provide You with reasonable prior notice of any
change in Subscription fees to give You an opportunity to terminate
Your Subscription before such change becomes effective.
</p>
<p>
Your continued use of the Service after the Subscription fee change
comes into effect constitutes Your agreement to pay the modified
Subscription fee amount.
</p>
<h3>Refunds</h3>
<p>
Except when required by law, paid Subscription fees are
non-refundable.
</p>
<p>
Certain refund requests for Subscriptions may be considered by the
Company on a case-by-case basis and granted at the sole discretion of
the Company.
</p>
<h3>Free Trial</h3>
<p>
The Company may, at its sole discretion, offer a Subscription with a
Free Trial for a limited period of time.
</p>
<p>
You may be required to enter Your billing information in order to sign
up for the Free Trial.
</p>
<p>
If You do enter Your billing information when signing up for a Free
Trial, You will not be charged by the Company until the Free Trial has
expired. On the last day of the Free Trial period, unless You canceled
Your Subscription, You will be automatically charged the applicable
Subscription fees for the type of Subscription You have selected.
</p>
<p>
At any time and without notice, the Company reserves the right to (i)
modify the terms and conditions of the Free Trial offer, or (ii)
cancel such Free Trial offer.
</p>
<h2>User Accounts</h2>
<p>
When You create an account with Us, You must provide Us information
that is accurate, complete, and current at all times. Failure to do so
constitutes a breach of the Terms, which may result in immediate
termination of Your account on Our Service.
</p>
<p>
You are responsible for safeguarding the password that You use to
access the Service and for any activities or actions under Your
password, whether Your password is with Our Service or a Third-Party
Social Media Service.
</p>
<p>
You agree not to disclose Your password to any third party. You must
notify Us immediately upon becoming aware of any breach of security or
unauthorized use of Your account.
</p>
<p>
You may not use as a username the name of another person or entity or
that is not lawfully available for use, a name or trademark that is
subject to any rights of another person or entity other than You
without appropriate authorization, or a name that is otherwise
offensive, vulgar or obscene.
</p>
<h2>Intellectual Property</h2>
<p>
The Service and its original content (excluding Content provided by
You or other users), features and functionality are and will remain
the exclusive property of the Company and its licensors.
</p>
<p>
The Service is protected by copyright, trademark, and other laws of
both the Country and foreign countries.
</p>
<p>
Our trademarks and trade dress may not be used in connection with any
product or service without the prior written consent of the Company.
</p>
<h2>Links to Other Websites</h2>
<p>
Our Service may contain links to third-party web sites or services
that are not owned or controlled by the Company.
</p>
<p>
The Company has no control over, and assumes no responsibility for,
the content, privacy policies, or practices of any third party web
sites or services. You further acknowledge and agree that the Company
shall not be responsible or liable, directly or indirectly, for any
damage or loss caused or alleged to be caused by or in connection with
the use of or reliance on any such content, goods or services
available on or through any such web sites or services.
</p>
<p>
We strongly advise You to read the terms and conditions and privacy
policies of any third-party web sites or services that You visit.
</p>
<h2>Termination</h2>
<p>
We may terminate or suspend Your Account immediately, without prior
notice or liability, for any reason whatsoever, including without
limitation if You breach these Terms and Conditions.
</p>
<p>
Upon termination, Your right to use the Service will cease
immediately. If You wish to terminate Your Account, You may simply
discontinue using the Service.
</p>
<h2>Limitation of Liability</h2>
<p>
Notwithstanding any damages that You might incur, the entire liability
of the Company and any of its suppliers under any provision of this
Terms and Your exclusive remedy for all of the foregoing shall be
limited to the amount actually paid by You through the Service or 100
USD if You haven't purchased anything through the Service.
</p>
<p>
To the maximum extent permitted by applicable law, in no event shall
the Company or its suppliers be liable for any special, incidental,
indirect, or consequential damages whatsoever (including, but not
limited to, damages for loss of profits, loss of data or other
information, for business interruption, for personal injury, loss of
privacy arising out of or in any way related to the use of or
inability to use the Service, third-party software and/or third-party
hardware used with the Service, or otherwise in connection with any
provision of this Terms), even if the Company or any supplier has been
advised of the possibility of such damages and even if the remedy
fails of its essential purpose.
</p>
<p>
Some states do not allow the exclusion of implied warranties or
limitation of liability for incidental or consequential damages, which
means that some of the above limitations may not apply. In these
states, each party's liability will be limited to the greatest extent
permitted by law.
</p>
<h2>&quot;AS IS&quot; and &quot;AS AVAILABLE&quot; Disclaimer</h2>
<p>
The Service is provided to You &quot;AS IS&quot; and &quot;AS
AVAILABLE&quot; and with all faults and defects without warranty of
any kind. To the maximum extent permitted under applicable law, the
Company, on its own behalf and on behalf of its Affiliates and its and
their respective licensors and service providers, expressly disclaims
all warranties, whether express, implied, statutory or otherwise, with
respect to the Service, including all implied warranties of
merchantability, fitness for a particular purpose, title and
non-infringement, and warranties that may arise out of course of
dealing, course of performance, usage or trade practice. Without
limitation to the foregoing, the Company provides no warranty or
undertaking, and makes no representation of any kind that the Service
will meet Your requirements, achieve any intended results, be
compatible or work with any other software, applications, systems or
services, operate without interruption, meet any performance or
reliability standards or be error free or that any errors or defects
can or will be corrected.
</p>
<p>
Without limiting the foregoing, neither the Company nor any of the
company's provider makes any representation or warranty of any kind,
express or implied: (i) as to the operation or availability of the
Service, or the information, content, and materials or products
included thereon; (ii) that the Service will be uninterrupted or
error-free; (iii) as to the accuracy, reliability, or currency of any
information or content provided through the Service; or (iv) that the
Service, its servers, the content, or e-mails sent from or on behalf
of the Company are free of viruses, scripts, trojan horses, worms,
malware, timebombs or other harmful components.
</p>
<p>
Some jurisdictions do not allow the exclusion of certain types of
warranties or limitations on applicable statutory rights of a
consumer, so some or all of the above exclusions and limitations may
not apply to You. But in such a case the exclusions and limitations
set forth in this section shall be applied to the greatest extent
enforceable under applicable law.
</p>
<h2>Governing Law</h2>
<p>
The laws of the Country, excluding its conflicts of law rules, shall
govern this Terms and Your use of the Service. Your use of the
Application may also be subject to other local, state, national, or
international laws.
</p>
<h2>Disputes Resolution</h2>
<p>
If You have any concern or dispute about the Service, You agree to
first try to resolve the dispute informally by contacting the Company.
</p>
<h2>For European Union (EU) Users</h2>
<p>
If You are a European Union consumer, you will benefit from any
mandatory provisions of the law of the country in which You are
resident.
</p>
<h2>United States Federal Government End Use Provisions</h2>
<p>
If You are a U.S. federal government end user, our Service is a
&quot;Commercial Item&quot; as that term is defined at 48 C.F.R.
§2.101.
</p>
<h2>United States Legal Compliance</h2>
<p>
You represent and warrant that (i) You are not located in a country
that is subject to the United States government embargo, or that has
been designated by the United States government as a &quot;terrorist
supporting&quot; country, and (ii) You are not listed on any United
States government list of prohibited or restricted parties.
</p>
<h2>Severability and Waiver</h2>
<h3>Severability</h3>
<p>
If any provision of these Terms is held to be unenforceable or
invalid, such provision will be changed and interpreted to accomplish
the objectives of such provision to the greatest extent possible under
applicable law and the remaining provisions will continue in full
force and effect.
</p>
<h3>Waiver</h3>
<p>
Except as provided herein, the failure to exercise a right or to
require performance of an obligation under these Terms shall not
affect a party's ability to exercise such right or require such
performance at any time thereafter nor shall the waiver of a breach
constitute a waiver of any subsequent breach.
</p>
<h2>Translation Interpretation</h2>
<p>
These Terms and Conditions may have been translated if We have made
them available to You on our Service. You agree that the original
English text shall prevail in the case of a dispute.
</p>
<h2>Changes to These Terms and Conditions</h2>
<p>
We reserve the right, at Our sole discretion, to modify or replace
these Terms at any time. If a revision is material We will make
reasonable efforts to provide at least 30 days' notice prior to any
new terms taking effect. What constitutes a material change will be
determined at Our sole discretion.
</p>
<p>
By continuing to access or use Our Service after those revisions
become effective, You agree to be bound by the revised terms. If You
do not agree to the new terms, in whole or in part, please stop using
the website and the Service.
</p>
<h2>Contact Us</h2>
<p>
If you have any questions about these Terms and Conditions, You can
contact us:
</p>
<ul>
<li>By email: hello@openpanel.dev</li>
</ul>
</article>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { cn } from '@/utils/cn';
import Image from 'next/image';
import Link from 'next/link';
interface LogoProps {
className?: string;
@@ -7,7 +8,8 @@ interface LogoProps {
export function Logo({ className }: LogoProps) {
return (
<div
<Link
href="/"
className={cn('text-xl font-medium flex gap-2 items-center', className)}
>
<Image
@@ -18,6 +20,6 @@ export function Logo({ className }: LogoProps) {
height={32}
/>
openpanel.dev
</div>
</Link>
);
}

View File

@@ -75,7 +75,7 @@ const config = {
},
},
},
plugins: [require('tailwindcss-animate')],
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
};
export default config;

View File

@@ -14,6 +14,6 @@ export function isBot(ua: string) {
return {
name: res.name,
type: res.category,
type: res.category || 'Unknown',
};
}

View File

@@ -1,12 +1,13 @@
import { isBot } from '@/bots';
import { getClientIp, parseIp } from '@/utils/parseIp';
import { getReferrerWithQuery, parseReferrer } from '@/utils/parseReferrer';
import { parseUserAgent } from '@/utils/parseUserAgent';
import { isUserAgentSet, parseUserAgent } from '@/utils/parseUserAgent';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { omit } from 'ramda';
import { generateProfileId, getTime, toISOString } from '@mixan/common';
import { generateDeviceId, getTime, toISOString } from '@mixan/common';
import type { IServiceCreateEventPayload } from '@mixan/db';
import { getSalts } from '@mixan/db';
import { createBotEvent, getEvents, getSalts } from '@mixan/db';
import type { JobsOptions } from '@mixan/queue';
import { eventsQueue, findJobByPrefix } from '@mixan/queue';
import type { PostEventPayload } from '@mixan/types';
@@ -66,9 +67,10 @@ export async function postEvent(
}>,
reply: FastifyReply
) {
let profileId: string | null = null;
let deviceId: string | null = null;
const projectId = request.projectId;
const body = request.body;
const profileId = body.profileId ?? '';
const createdAt = new Date(body.timestamp);
const url = body.properties?.path;
const { path, hash, query } = parsePath(url);
@@ -81,68 +83,118 @@ export async function postEvent(
const ua = request.headers['user-agent']!;
const uaInfo = parseUserAgent(ua);
const salts = await getSalts();
const currentProfileId = generateProfileId({
const currentDeviceId = generateDeviceId({
salt: salts.current,
origin,
ip,
ua,
});
const previousProfileId = generateProfileId({
const previousProfileId = generateDeviceId({
salt: salts.previous,
origin,
ip,
ua,
});
const isServerEvent = !ip && !origin && !isUserAgentSet(ua);
if (isServerEvent) {
const [event] = await getEvents(
`SELECT * FROM events WHERE name = 'screen_view' AND profile_id = '${profileId}' AND project_id = '${projectId}' ORDER BY created_at DESC LIMIT 1`
);
eventsQueue.add('event', {
type: 'createEvent',
payload: {
name: body.name,
deviceId: event?.deviceId || '',
profileId,
projectId,
properties: body.properties ?? {},
createdAt,
country: event?.country ?? '',
city: event?.city ?? '',
region: event?.region ?? '',
continent: event?.continent ?? '',
os: event?.os ?? '',
osVersion: event?.osVersion ?? '',
browser: event?.browser ?? '',
browserVersion: event?.browserVersion ?? '',
device: event?.device ?? '',
brand: event?.brand ?? '',
model: event?.model ?? '',
duration: 0,
path: event?.path ?? '',
referrer: event?.referrer ?? '',
referrerName: event?.referrerName ?? '',
referrerType: event?.referrerType ?? '',
profile: undefined,
meta: undefined,
},
});
return reply.status(200).send('');
}
const bot = isBot(ua);
if (bot) {
await createBotEvent({
...bot,
projectId,
createdAt: new Date(body.timestamp),
});
return reply.status(200).send('');
}
const [geo, eventsJobs] = await Promise.all([
parseIp(ip),
eventsQueue.getJobs(['delayed']),
]);
// find session_end job
const sessionEndJobCurrentProfileId = findJobByPrefix(
const sessionEndJobCurrentDeviceId = findJobByPrefix(
eventsJobs,
`sessionEnd:${projectId}:${currentProfileId}:`
`sessionEnd:${projectId}:${currentDeviceId}:`
);
const sessionEndJobPreviousProfileId = findJobByPrefix(
const sessionEndJobPreviousDeviceId = findJobByPrefix(
eventsJobs,
`sessionEnd:${projectId}:${previousProfileId}:`
);
const createSessionStart =
!sessionEndJobCurrentProfileId && !sessionEndJobPreviousProfileId;
!sessionEndJobCurrentDeviceId && !sessionEndJobPreviousDeviceId;
if (sessionEndJobCurrentProfileId && !sessionEndJobPreviousProfileId) {
if (sessionEndJobCurrentDeviceId && !sessionEndJobPreviousDeviceId) {
console.log('found session current');
profileId = currentProfileId;
const diff = Date.now() - sessionEndJobCurrentProfileId.timestamp;
sessionEndJobCurrentProfileId.changeDelay(diff + SESSION_END_TIMEOUT);
} else if (!sessionEndJobCurrentProfileId && sessionEndJobPreviousProfileId) {
deviceId = currentDeviceId;
const diff = Date.now() - sessionEndJobCurrentDeviceId.timestamp;
sessionEndJobCurrentDeviceId.changeDelay(diff + SESSION_END_TIMEOUT);
} else if (!sessionEndJobCurrentDeviceId && sessionEndJobPreviousDeviceId) {
console.log('found session previous');
profileId = previousProfileId;
const diff = Date.now() - sessionEndJobPreviousProfileId.timestamp;
sessionEndJobPreviousProfileId.changeDelay(diff + SESSION_END_TIMEOUT);
deviceId = previousProfileId;
const diff = Date.now() - sessionEndJobPreviousDeviceId.timestamp;
sessionEndJobPreviousDeviceId.changeDelay(diff + SESSION_END_TIMEOUT);
} else {
console.log('new session with current');
profileId = currentProfileId;
deviceId = currentDeviceId;
// Queue session end
eventsQueue.add(
'event',
{
type: 'createSessionEnd',
payload: {
profileId,
deviceId,
},
},
{
delay: SESSION_END_TIMEOUT,
jobId: `sessionEnd:${projectId}:${profileId}:${Date.now()}`,
jobId: `sessionEnd:${projectId}:${deviceId}:${Date.now()}`,
}
);
}
const payload: Omit<IServiceCreateEventPayload, 'id'> = {
name: body.name,
deviceId,
profileId,
projectId,
properties: Object.assign({}, omit(['path', 'referrer'], body.properties), {
@@ -170,7 +222,7 @@ export async function postEvent(
meta: undefined,
};
const job = findJobByPrefix(eventsJobs, `event:${projectId}:${profileId}:`);
const job = findJobByPrefix(eventsJobs, `event:${projectId}:${deviceId}:`);
if (job?.isDelayed && job.data.type === 'createEvent') {
const prevEvent = job.data.payload;
@@ -208,7 +260,7 @@ export async function postEvent(
const options: JobsOptions = {};
if (payload.name === 'screen_view') {
options.delay = SESSION_TIMEOUT;
options.jobId = `event:${projectId}:${profileId}:${Date.now()}`;
options.jobId = `event:${projectId}:${deviceId}:${Date.now()}`;
}
// Queue current event
@@ -221,5 +273,5 @@ export async function postEvent(
options
);
reply.status(202).send(profileId);
reply.status(202).send(deviceId);
}

View File

@@ -85,8 +85,6 @@ export async function getFavicon(
// TRY FAVICON.ICO
const buffer = await getImageBuffer(`${origin}/favicon.ico`);
console.log('buffer', buffer?.length);
if (buffer && buffer.byteLength > 0) {
return sendBuffer(buffer, hostname);
}

View File

@@ -1,195 +1,38 @@
import { getClientIp, parseIp } from '@/utils/parseIp';
import { parseUserAgent } from '@/utils/parseUserAgent';
import { isUserAgentSet, parseUserAgent } from '@/utils/parseUserAgent';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, mergeDeepRight, path } from 'ramda';
import { assocPath, pathOr } from 'ramda';
import { generateProfileId, toDots } from '@mixan/common';
import type { IDBProfile } from '@mixan/db';
import { db, getSalts } from '@mixan/db';
import { getProfileById, upsertProfile } from '@mixan/db';
import type {
IncrementProfilePayload,
UpdateProfilePayload,
} from '@mixan/types';
async function findProfile({
profileId,
ip,
origin,
ua,
}: {
profileId: string | null;
ip: string;
origin: string;
ua: string;
}) {
const salts = await getSalts();
const currentProfileId = generateProfileId({
salt: salts.current,
origin,
ip,
ua,
});
const previousProfileId = generateProfileId({
salt: salts.previous,
origin,
ip,
ua,
});
const ids = [currentProfileId, previousProfileId];
if (profileId) {
ids.push(profileId);
}
const profiles = await db.profile.findMany({
where: {
id: {
in: ids,
},
},
});
return profiles.find((p) => {
return (
p.id === profileId ||
p.id === currentProfileId ||
p.id === previousProfileId
);
}) as IDBProfile | undefined;
}
export async function updateProfile(
request: FastifyRequest<{
Body: UpdateProfilePayload;
}>,
reply: FastifyReply
) {
const body = request.body;
const profileId: string | null = body.profileId ?? null;
const { profileId, properties, ...rest } = request.body;
const projectId = request.projectId;
const ip = getClientIp(request)!;
const origin = request.headers.origin ?? projectId;
const ua = request.headers['user-agent']!;
const salts = await getSalts();
const uaInfo = parseUserAgent(ua);
const geo = await parseIp(ip);
if (profileId === null) {
const currentProfileId = generateProfileId({
salt: salts.current,
origin,
ip,
ua,
});
const previousProfileId = generateProfileId({
salt: salts.previous,
origin,
ip,
ua,
});
const profiles = await db.profile.findMany({
where: {
id: {
in: [currentProfileId, previousProfileId],
},
},
});
if (profiles.length === 0) {
const profile = await db.profile.create({
data: {
id: currentProfileId,
external_id: body.id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
avatar: body.avatar,
project_id: projectId,
properties: body.properties ?? {},
// ...uaInfo,
// ...geo,
},
});
return reply.status(201).send(profile);
}
const currentProfile = profiles.find((p) => p.id === currentProfileId);
const previousProfile = profiles.find((p) => p.id === previousProfileId);
const profile = currentProfile ?? previousProfile;
if (profile) {
await db.profile.update({
where: {
id: profile.id,
},
data: {
external_id: body.id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
avatar: body.avatar,
properties: toDots(
mergeDeepRight(
profile.properties as Record<string, unknown>,
body.properties ?? {}
)
),
// ...uaInfo,
// ...geo,
},
});
return reply.status(200).send(profile.id);
}
return reply.status(200).send();
}
const profile = await db.profile.findUnique({
where: {
id: profileId,
await upsertProfile({
id: profileId,
projectId,
properties: {
...(properties ?? {}),
...(ip ? geo : {}),
...(isUserAgentSet(ua) ? uaInfo : {}),
},
...rest,
});
if (profile) {
await db.profile.update({
where: {
id: profile.id,
},
data: {
external_id: body.id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
avatar: body.avatar,
properties: toDots(
mergeDeepRight(
profile.properties as Record<string, unknown>,
body.properties ?? {}
)
),
// ...uaInfo,
// ...geo,
},
});
} else {
await db.profile.create({
data: {
id: profileId,
external_id: body.id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
avatar: body.avatar,
project_id: projectId,
properties: body.properties ?? {},
// ...uaInfo,
// ...geo,
},
});
}
reply.status(202).send(profileId);
}
@@ -199,43 +42,33 @@ export async function incrementProfileProperty(
}>,
reply: FastifyReply
) {
const body = request.body;
const profileId: string | null = body.profileId ?? null;
const { profileId, property, value } = request.body;
const projectId = request.projectId;
const ip = getClientIp(request)!;
const origin = request.headers.origin ?? projectId;
const ua = request.headers['user-agent']!;
const profile = await findProfile({
ip,
origin,
ua,
profileId,
});
const profile = await getProfileById(profileId);
if (!profile) {
return reply.status(404).send('Not found');
}
const property = path(body.property.split('.'), profile.properties);
const parsed = parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10
);
if (typeof property !== 'number' && typeof property !== 'undefined') {
if (isNaN(parsed)) {
return reply.status(400).send('Not number');
}
profile.properties = assocPath(
body.property.split('.'),
property ? property + body.value : body.value,
property.split('.'),
parsed + value,
profile.properties
);
await db.profile.update({
where: {
id: profile.id,
},
data: {
properties: profile.properties as any,
},
await upsertProfile({
id: profile.id,
projectId,
properties: profile.properties,
});
reply.status(202).send(profile.id);
@@ -247,43 +80,33 @@ export async function decrementProfileProperty(
}>,
reply: FastifyReply
) {
const body = request.body;
const profileId: string | null = body.profileId ?? null;
const { profileId, property, value } = request.body;
const projectId = request.projectId;
const ip = getClientIp(request)!;
const origin = request.headers.origin ?? projectId;
const ua = request.headers['user-agent']!;
const profile = await findProfile({
ip,
origin,
ua,
profileId,
});
const profile = await getProfileById(profileId);
if (!profile) {
return reply.status(404).send('Not found');
}
const property = path(body.property.split('.'), profile.properties);
const parsed = parseInt(
pathOr<string>('0', property.split('.'), profile.properties),
10
);
if (typeof property !== 'number') {
if (isNaN(parsed)) {
return reply.status(400).send('Not number');
}
profile.properties = assocPath(
body.property.split('.'),
property ? property - body.value : -body.value,
property.split('.'),
parsed - value,
profile.properties
);
await db.profile.update({
where: {
id: profile.id,
},
data: {
properties: profile.properties as any,
},
await upsertProfile({
id: profile.id,
projectId,
properties: profile.properties,
});
reply.status(202).send(profile.id);

View File

@@ -59,7 +59,10 @@ const startServer = async () => {
}
}
await fastify.listen({ host: '0.0.0.0', port });
await fastify.listen({
host: process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost',
port,
});
// Notify when keys expires
redisPub.config('SET', 'notify-keyspace-events', 'Ex');

View File

@@ -1,5 +1,9 @@
import { UAParser } from 'ua-parser-js';
export function isUserAgentSet(ua: string) {
return ua !== 'node' && ua !== 'undici' && !!ua;
}
export function parseUserAgent(ua: string) {
const res = new UAParser(ua).getResult();
return {

View File

@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@@ -6,7 +6,12 @@
/** @type {import("next").NextConfig} */
const config = {
reactStrictMode: false,
transpilePackages: ['@mixan/types', '@mixan/sdk', '@mixan/web-sdk'],
transpilePackages: [
'@mixan/types',
'@mixan/sdk',
'@mixan/web-sdk',
'@mixan/next',
],
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
/**

View File

@@ -11,11 +11,12 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "13.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"@mixan-test/next": "workspace:@mixan/next@*",
"@mixan-test/sdk": "workspace:@mixan/sdk@*",
"@mixan-test/sdk-web": "workspace:@mixan/sdk-web@*"
"@mixan-test/sdk-web": "workspace:@mixan/sdk-web@*",
"next": "~14.1.0",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",

View File

@@ -1,2 +1,2 @@
"use strict";(()=>{function w(s){return Promise.all(Object.entries(s).map(async([t,e])=>[t,await e??""])).then(t=>Object.fromEntries(t))}function P(s){let t={"Content-Type":"application/json"};return{headers:t,async fetch(e,i,n){let o=`${s}${e}`,u,m=await w(t);return new Promise(c=>{let h=r=>{clearTimeout(u),fetch(o,{headers:m,method:"POST",body:JSON.stringify(i??{}),keepalive:!0,...n??{}}).then(async a=>{if(a.status!==200&&a.status!==202)return f(r,c);let g=await a.text();if(!g)return c(null);c(g)}).catch(()=>f(r,c))};function f(r,a){if(r>1)return a(null);u=setTimeout(()=>{h(r+1)},Math.pow(2,r)*500)}h(0)})}}}var l=class{options;api;state={properties:{}};constructor(t){this.options=t,this.api=P(t.url),this.api.headers["mixan-client-id"]=t.clientId,this.options.clientSecret&&(this.api.headers["mixan-client-secret"]=this.options.clientSecret)}init(t){this.state.properties=t??{}}setUser(t){this.api.fetch("/profile",{profileId:this.getProfileId(),...t,properties:{...this.state.properties,...t.properties}})}increment(t,e){this.api.fetch("/profile/increment",{property:t,value:e,profileId:this.getProfileId()})}decrement(t,e){this.api.fetch("/profile/decrement",{property:t,value:e,profileId:this.getProfileId()})}event(t,e){this.api.fetch("/event",{name:t,properties:{...this.state.properties,...e??{}},timestamp:this.timestamp(),profileId:this.getProfileId()}).then(i=>{this.options.setProfileId&&i&&this.options.setProfileId(i)})}setGlobalProperties(t){this.state.properties={...this.state.properties,...t}}clear(){this.state.profileId=void 0,this.options.removeProfileId&&this.options.removeProfileId()}timestamp(){return new Date().toISOString()}getProfileId(){if(this.state.profileId)return this.state.profileId;this.options.getProfileId&&(this.state.profileId=this.options.getProfileId()||void 0)}};var d=class extends l{lastPath="";constructor(t){super(t),this.isServer()||(this.setGlobalProperties({referrer:document.referrer}),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews())}isServer(){return typeof document>"u"}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",t=>{let e=t.target,i=e.closest("a");if(i&&e){let n=i.getAttribute("href");n?.startsWith("http")&&super.event("link_out",{href:n,text:i.innerText||i.getAttribute("title")||e.getAttribute("alt")||e.getAttribute("title")})}})}trackScreenViews(){if(this.isServer())return;let t=history.pushState;history.pushState=function(...n){let o=t.apply(this,n);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),o};let e=history.replaceState;history.replaceState=function(...n){let o=e.apply(this,n);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),o},window.addEventListener("popstate",()=>window.dispatchEvent(new Event("locationchange"))),this.options.hash?window.addEventListener("hashchange",()=>this.screenView()):window.addEventListener("locationchange",()=>this.screenView()),this.screenView()}screenView(t){if(this.isServer())return;let e=window.location.href;this.lastPath!==e&&(this.lastPath=e,super.event("screen_view",{...t??{},path:e,title:document.title}))}};var p=document.currentScript;p&&(window.openpanel=new d({url:p?.getAttribute("data-url"),clientId:p?.getAttribute("data-client-id"),trackOutgoingLinks:!!p?.getAttribute("data-track-outgoing-links"),trackScreenViews:!!p?.getAttribute("data-track-screen-views")}));})();
"use strict";(()=>{function v(r){return Promise.all(Object.entries(r).map(async([t,e])=>[t,await e??""])).then(t=>Object.fromEntries(t))}function m(r){let t={"Content-Type":"application/json"};return{headers:t,async fetch(e,i,n){let s=`${r}${e}`,c,l=await v(t);return new Promise(o=>{let h=a=>{clearTimeout(c),fetch(s,{headers:l,method:"POST",body:JSON.stringify(i??{}),keepalive:!0,...n??{}}).then(async p=>{if(p.status!==200&&p.status!==202)return f(a,o);let g=await p.text();if(!g)return o(null);o(g)}).catch(()=>f(a,o))};function f(a,p){if(a>1)return p(null);c=setTimeout(()=>{h(a+1)},Math.pow(2,a)*500)}h(0)})}}}var d=class{options;api;state={properties:{}};constructor(t){this.options=t,this.api=m(t.url),this.api.headers["mixan-client-id"]=t.clientId,this.options.clientSecret&&(this.api.headers["mixan-client-secret"]=this.options.clientSecret)}init(t){this.state.properties=t??{}}setProfileId(t){this.state.profileId=t}setProfile(t){this.setProfileId(t.profileId),this.api.fetch("/profile",{...t,properties:{...this.state.properties,...t.properties}})}increment(t,e,i){let n=i?.profileId??this.state.profileId;if(!n)return console.log("No profile id");this.api.fetch("/profile/increment",{profileId:n,property:t,value:e})}decrement(t,e,i){let n=i?.profileId??this.state.profileId;if(!n)return console.log("No profile id");this.api.fetch("/profile/decrement",{profileId:n,property:t,value:e})}event(t,e){let i=e?.profileId??this.state.profileId;delete e?.profileId,this.api.fetch("/event",{name:t,properties:{...this.state.properties,...e??{}},timestamp:this.timestamp(),deviceId:this.getDeviceId(),profileId:i}).then(n=>{this.options.setDeviceId&&n&&this.options.setDeviceId(n)})}setGlobalProperties(t){this.state.properties={...this.state.properties,...t}}clear(){this.state.properties={},this.state.deviceId=void 0,this.options.removeDeviceId&&this.options.removeDeviceId()}timestamp(){return new Date().toISOString()}getDeviceId(){if(this.state.deviceId)return this.state.deviceId;this.options.getDeviceId&&(this.state.deviceId=this.options.getDeviceId()||void 0)}};function b(r){return r.replace(/([-_][a-z])/gi,t=>t.toUpperCase().replace("-","").replace("_",""))}var u=class extends d{lastPath="";constructor(t){super(t),this.isServer()||(this.setGlobalProperties({referrer:document.referrer}),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews(),this.options.trackAttributes&&this.trackAttributes())}isServer(){return typeof document>"u"}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",t=>{let e=t.target,i=e.closest("a");if(i&&e){let n=i.getAttribute("href");n?.startsWith("http")&&super.event("link_out",{href:n,text:i.innerText||i.getAttribute("title")||e.getAttribute("alt")||e.getAttribute("title")})}})}trackScreenViews(){if(this.isServer())return;let t=history.pushState;history.pushState=function(...n){let s=t.apply(this,n);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),s};let e=history.replaceState;history.replaceState=function(...n){let s=e.apply(this,n);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),s},window.addEventListener("popstate",()=>window.dispatchEvent(new Event("locationchange"))),this.options.hash?window.addEventListener("hashchange",()=>this.screenView()):window.addEventListener("locationchange",()=>this.screenView()),setTimeout(()=>{this.screenView()},50)}trackAttributes(){this.isServer()||document.addEventListener("click",t=>{let e=t.target,i=e.closest("button"),n=e.closest("button"),s=i?.getAttribute("data-event")?i:n?.getAttribute("data-event")?n:null;if(s){let c={};for(let o of s.attributes)o.name.startsWith("data-")&&o.name!=="data-event"&&(c[b(o.name.replace(/^data-/,""))]=o.value);let l=s.getAttribute("data-event");l&&super.event(l,c)}})}screenView(t){if(this.isServer())return;let e=window.location.href;this.lastPath!==e&&(this.lastPath=e,super.event("screen_view",{...t??{},path:e,title:document.title}))}};(r=>{if(r.op&&"q"in r.op){let t=r.op.q||[],e=new u(t.shift()[1]);t.forEach(i=>{i[0]in e&&e[i[0]](...i.slice(1))}),r.op=(i,...n)=>{let s=e[i].bind(e);typeof s=="function"&&s(...n)}}})(window);})();
//# sourceMappingURL=cdn.global.js.map

View File

@@ -0,0 +1,12 @@
'use client';
export default function TestPage({ triggerEvent }: { triggerEvent: any }) {
return (
<>
<button onClick={() => triggerEvent()}>Event (server action)</button>
<button data-event="yolo" data-yolo="123" data-hihi="taaa-daaaaa">
Event (data-attributes)
</button>
</>
);
}

View File

@@ -0,0 +1,55 @@
import { OpenpanelProvider, SetProfileId, trackEvent } from '@mixan-test/next';
import { Mixan as Openpanel } from '@mixan-test/sdk';
const opServer = new Openpanel({
clientId: '4c9a28cb-73c3-429f-beaf-4b3fe91352ea',
clientSecret: '2701ada9-fcbf-414a-ac94-9511949ee44d',
url: 'https://api.openpanel.dev',
});
export default function Page() {
// Track event in server actions
async function create() {
'use server';
opServer.event('some-event', {
profileId: '1234',
});
}
return (
<div>
{/* In layout.tsx (app dir) or _app.tsx (pages) */}
<OpenpanelProvider
clientId="0acce97f-1126-4439-b7ee-5d384e2fc94b"
url="https://api.openpanel.dev"
trackScreenViews
trackAttributes
trackOutgoingLinks
/>
{/* Provide user id in React Server Components */}
<SetProfileId value="1234" />
<button
onClick={() =>
trackEvent('some-event', {
bar: 'bar',
foo: 'foo',
revenue: 1000,
})
}
>
Track event with method
</button>
<button
data-event="some-event"
data-bar="bar"
data-foo="foo"
data-revenue="1000"
>
Track event with attributes
</button>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { OpenpanelProvider } from '@mixan-test/next';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<OpenpanelProvider
clientId="0acce97f-1126-4439-b7ee-5d384e2fc94b"
url="http://localhost:3333"
trackScreenViews
trackAttributes
trackOutgoingLinks
/>
{children}
</body>
</html>
);
}

View File

@@ -1,15 +1,15 @@
import { useEffect } from 'react';
// import { mixan } from '@/analytics';
import { OpenpanelProvider } from '@mixan-test/next';
import type { AppProps } from 'next/app';
import { useRouter } from 'next/router';
export default function MyApp({ Component, pageProps }: AppProps) {
const router = useRouter();
// useEffect(() => {
// mixan.screenView();
// return router.events.on('routeChangeComplete', () => {
// mixan.screenView();
// });
// }, []);
return <Component {...pageProps} />;
return (
<>
<OpenpanelProvider
clientId="0acce97f-1126-4439-b7ee-5d384e2fc94b"
url="http://localhost:3333"
trackScreenViews
/>
<Component {...pageProps} />
</>
);
}

View File

@@ -1,22 +0,0 @@
import { Head, Html, Main, NextScript } from 'next/document';
export default function Document() {
return (
<Html>
<Head>
<script
async
src="/op.js"
data-url="http://localhost:3333"
data-client-id="0acce97f-1126-4439-b7ee-5d384e2fc94b"
data-track-screen-views="1"
data-track-outgoing-links="1"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}

View File

@@ -1,17 +1,68 @@
import { useEffect, useState } from 'react';
import {
clear,
decrement,
increment,
setProfile,
trackEvent,
} from '@mixan-test/next';
import Link from 'next/link';
export default function Test() {
const [id, setId] = useState('');
const [auth, setAuth] = useState<string | null>(null);
function handleLogin() {
if (id) {
localStorage.setItem('auth', id);
setAuth(id);
}
}
function handleLogout() {
localStorage.removeItem('auth');
setAuth(null);
}
useEffect(() => {
setAuth(localStorage.getItem('auth') ?? null);
}, []);
useEffect(() => {
console.log('auth', auth);
if (auth) {
console.log('set profile?', auth);
setProfile({
profileId: auth,
});
} else {
clear();
}
}, [auth]);
if (auth === null) {
return (
<div>
<input
type="text"
placeholder="Login with user id"
onChange={(e) => setId(e.target.value)}
/>
<button onClick={handleLogin}>Login</button>
</div>
);
}
return (
<div>
<Link href="/">Home</Link>
<button
onClick={() => {
// @ts-expect-error
window.openpanel.setUser({
first_name: 'John',
last_name: 'Doe',
email: 'john.doe@gmail.com',
id: '1234',
setProfile({
firstName: 'Maja',
lastName: 'Klara',
profileId: auth,
});
}}
>
@@ -19,16 +70,14 @@ export default function Test() {
</button>
<button
onClick={() => {
// @ts-expect-error
window.openpanel.increment('app_open', 1);
increment('app_open', 1);
}}
>
Increment
</button>
<button
onClick={() => {
// @ts-expect-error
window.openpanel.decrement('app_open', 1);
decrement('app_open', 1);
}}
>
Decrement
@@ -43,8 +92,7 @@ export default function Test() {
</button>
<button
onClick={() => {
// @ts-ignore
window.openpanel.event('custom_click', {
trackEvent('custom_click', {
custom_string: 'test',
custom_number: 1,
});
@@ -52,14 +100,7 @@ export default function Test() {
>
Trigger event
</button>
<button
onClick={() => {
// @ts-expect-error
window.openpanel.clear();
}}
>
Logout
</button>
<button onClick={handleLogout}>Logout</button>
</div>
);
}

View File

@@ -3,15 +3,23 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
},
"plugins": [
{
"name": "next"
}
],
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
"strictNullChecks": true
},
"include": [".", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
".",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -59,7 +59,12 @@ export function EventList({ data, count }: EventListProps) {
<EventListItem key={item.id} {...item} />
))}
</div>
<Pagination cursor={cursor} setCursor={setCursor} />
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</>
)}
</div>

View File

@@ -6,6 +6,7 @@ import {
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { parseAsInteger } from 'nuqs';
import { getEventList, getEventsCount } from '@mixan/db';
@@ -28,20 +29,13 @@ const nuqsOptions = {
shallow: false,
};
function parseQueryAsNumber(value: string | undefined) {
if (typeof value === 'string') {
return parseInt(value, 10);
}
return undefined;
}
export default async function Page({
params: { projectId, organizationId },
searchParams,
}: PageProps) {
const [events, count] = await Promise.all([
getEventList({
cursor: parseQueryAsNumber(searchParams.cursor),
cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined,
projectId,
take: 50,
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
@@ -59,6 +53,7 @@ export default async function Page({
<PageLayout title="Events" organizationSlug={organizationId}>
<StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer
mode="events"
projectId={projectId}
nuqsOptions={nuqsOptions}
enableEventsFilter

View File

@@ -2,8 +2,10 @@
import { useEffect, useState } from 'react';
import { Logo } from '@/components/Logo';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { Rotate as Hamburger } from 'hamburger-react';
import { PlusIcon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
@@ -15,10 +17,14 @@ import LayoutOrganizationSelector from './layout-organization-selector';
interface LayoutSidebarProps {
organizations: IServiceOrganization[];
dashboards: IServiceDashboards;
organizationId: string;
projectId: string;
}
export function LayoutSidebar({
organizations,
dashboards,
organizationId,
projectId,
}: LayoutSidebarProps) {
const [active, setActive] = useState(false);
const pathname = usePathname();
@@ -56,11 +62,18 @@ export function LayoutSidebar({
<div className="flex flex-col p-4 gap-2 flex-grow overflow-auto">
<LayoutMenu dashboards={dashboards} />
{/* Placeholder for LayoutOrganizationSelector */}
<div className="h-16 block shrink-0"></div>
<div className="h-32 block shrink-0"></div>
</div>
<div className="fixed bottom-0 left-0 right-0">
<div className="bg-gradient-to-t from-white to-white/0 h-8 w-full"></div>
<div className="bg-white p-4 pt-0">
<div className="bg-white p-4 pt-0 flex flex-col gap-2">
<Link
className={cn('flex gap-2', buttonVariants())}
href={`/${organizationId}/${projectId}/reports`}
>
<PlusIcon size={16} />
Create a report
</Link>
<LayoutOrganizationSelector organizations={organizations} />
</div>
</div>

View File

@@ -9,12 +9,13 @@ interface AppLayoutProps {
children: React.ReactNode;
params: {
organizationId: string;
projectId: string;
};
}
export default async function AppLayout({
children,
params: { organizationId },
params: { organizationId, projectId },
}: AppLayoutProps) {
const [organizations, dashboards] = await Promise.all([
getCurrentOrganizations(),
@@ -23,7 +24,9 @@ export default async function AppLayout({
return (
<div id="dashboard">
<LayoutSidebar {...{ organizations, dashboards }} />
<LayoutSidebar
{...{ organizationId, projectId, organizations, dashboards }}
/>
<div className="lg:pl-72 transition-all">{children}</div>
</div>
);

View File

@@ -19,7 +19,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const reports = [
{
id: 'Unique visitors',
id: 'Visitors',
projectId,
events: [
{
@@ -27,20 +27,20 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
filters,
id: 'A',
name: 'session_start',
displayName: 'Unique visitors',
displayName: 'Visitors',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Unique visitors',
name: 'Visitors',
range,
previous,
metric: 'sum',
},
{
id: 'Total sessions',
id: 'Sessions',
projectId,
events: [
{
@@ -48,20 +48,20 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
filters,
id: 'A',
name: 'session_start',
displayName: 'Total sessions',
displayName: 'Sessions',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Total sessions',
name: 'Sessions',
range,
previous,
metric: 'sum',
},
{
id: 'Total pageviews',
id: 'Pageviews',
projectId,
events: [
{
@@ -69,14 +69,14 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
filters,
id: 'A',
name: 'screen_view',
displayName: 'Total pageviews',
displayName: 'Pageviews',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval,
name: 'Total pageviews',
name: 'Pageviews',
range,
previous,
metric: 'sum',

View File

@@ -42,7 +42,7 @@ export default async function Page({
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFiltersDrawer projectId={projectId} />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />

View File

@@ -1,13 +1,35 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { ListProperties } from '@/components/events/ListProperties';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { Chart } from '@/components/report/chart';
import { GradientBackground } from '@/components/ui/gradient-background';
import { KeyValue } from '@/components/ui/key-value';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import {
eventQueryFiltersParser,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { formatDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import { notFound } from 'next/navigation';
import { parseAsInteger } from 'nuqs';
import { getProfileById, getProfilesByExternalId } from '@mixan/db';
import type { GetEventListOptions } from '@mixan/db';
import {
getConversionEventNames,
getEventList,
getEventMeta,
getEventsCount,
getProfileById,
getProfilesByExternalId,
} from '@mixan/db';
import type { IChartInput } from '@mixan/validation';
import { EventList } from '../../events/event-list';
import { StickyBelowHeader } from '../../layout-sticky-below-header';
import ListProfileEvents from './list-profile-events';
interface PageProps {
@@ -16,18 +38,90 @@ interface PageProps {
profileId: string;
organizationId: string;
};
searchParams: {
events?: string;
cursor?: string;
f?: string;
};
}
export default async function Page({
params: { projectId, profileId, organizationId },
searchParams,
}: PageProps) {
const [profile] = await Promise.all([
const eventListOptions: GetEventListOptions = {
projectId,
profileId,
take: 50,
cursor: parseAsInteger.parse(searchParams.cursor ?? '') ?? undefined,
events: eventQueryNamesFilter.parse(searchParams.events ?? ''),
filters: eventQueryFiltersParser.parse(searchParams.f ?? '') ?? undefined,
};
const [profile, events, count, conversions] = await Promise.all([
getProfileById(profileId),
getEventList(eventListOptions),
getEventsCount(eventListOptions),
getConversionEventNames(projectId),
getExists(organizationId, projectId),
]);
const profiles = (
await getProfilesByExternalId(profile.external_id, profile.project_id)
).filter((item) => item.id !== profile.id);
const chartSelectedEvents = [
{
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
],
id: 'A',
name: '*',
displayName: 'Events',
},
];
if (conversions.length) {
chartSelectedEvents.push({
segment: 'event',
filters: [
{
id: 'profile_id',
name: 'profile_id',
operator: 'is',
value: [profileId],
},
{
id: 'name',
name: 'name',
operator: 'is',
value: conversions.map((c) => c.name),
},
],
id: 'B',
name: '*',
displayName: 'Conversions',
});
}
const profileChart: IChartInput = {
projectId,
chartType: 'histogram',
events: chartSelectedEvents,
breakdowns: [],
lineType: 'monotone',
interval: 'day',
name: 'Events',
range: '7d',
previous: false,
metric: 'sum',
};
if (!profile) {
return notFound();
}
return (
<PageLayout
organizationSlug={organizationId}
@@ -38,50 +132,41 @@ export default async function Page({
</div>
}
>
<StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer
projectId={projectId}
mode="events"
nuqsOptions={{ shallow: false }}
/>
<OverviewFiltersButtons
nuqsOptions={{ shallow: false }}
className="p-0 justify-end"
/>
</StickyBelowHeader>
<div className="p-4">
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 mb-8">
<Widget>
<WidgetHead>
<span className="title">Properties</span>
</WidgetHead>
<ListProperties
data={profile.properties}
className="rounded-none border-none"
/>
<WidgetBody className="flex gap-2 flex-wrap">
{Object.entries(profile.properties)
.filter(([, value]) => !!value)
.map(([key, value]) => (
<KeyValue key={key} name={key} value={value} />
))}
</WidgetBody>
</Widget>
<Widget>
<WidgetHead>
<span className="title">Linked profile</span>
<span className="title">Events per day</span>
</WidgetHead>
{profiles.length > 0 ? (
<div className="flex flex-col gap-4">
{profiles.map((profile) => (
<div key={profile.id} className="border-b border-border">
<WidgetBody className="flex gap-4">
<ProfileAvatar {...profile} />
<div>
<div className="font-medium mt-1">
{getProfileName(profile)}
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 text-muted-foreground text-xs">
<span>{profile.id}</span>
<span>{formatDateTime(profile.createdAt)}</span>
</div>
</div>
</WidgetBody>
<ListProperties
data={profile.properties}
className="rounded-none border-none"
/>
</div>
))}
</div>
) : (
<div className="p-4">No linked profiles</div>
)}
<WidgetBody className="flex gap-2">
<Chart {...profileChart} />
</WidgetBody>
</Widget>
</div>
<ListProfileEvents projectId={projectId} profileId={profileId} />
<EventList data={events} count={count} />
</div>
</PageLayout>
);

View File

@@ -1,67 +0,0 @@
'use client';
import { useMemo } from 'react';
import { api } from '@/app/_trpc/client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination, usePagination } from '@/components/Pagination';
import { Input } from '@/components/ui/input';
import { UsersIcon } from 'lucide-react';
import { useQueryState } from 'nuqs';
import { ProfileListItem } from './profile-list-item';
interface ListProfilesProps {
projectId: string;
}
export function ListProfiles({ projectId }: ListProfilesProps) {
const [query, setQuery] = useQueryState('q');
const pagination = usePagination();
const profilesQuery = api.profile.list.useQuery(
{
projectId,
query,
...pagination,
},
{
keepPreviousData: true,
}
);
const profiles = useMemo(() => profilesQuery.data ?? [], [profilesQuery]);
return (
<>
<StickyBelowHeader className="p-4 flex justify-between">
<Input
placeholder="Search by name"
value={query ?? ''}
onChange={(event) => setQuery(event.target.value || null)}
/>
</StickyBelowHeader>
<div className="p-4">
{profiles.length === 0 ? (
<FullPageEmptyState title="No profiles" icon={UsersIcon}>
{query ? (
<p>
No match for <strong>"{query}"</strong>
</p>
) : (
<p>We could not find any profiles on this project</p>
)}
</FullPageEmptyState>
) : (
<>
<div className="flex flex-col gap-4">
{profiles.map((item) => (
<ProfileListItem key={item.id} {...item} />
))}
</div>
<div className="mt-2">
<Pagination {...pagination} />
</div>
</>
)}
</div>
</>
);
}

View File

@@ -1,21 +1,62 @@
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 { eventQueryFiltersParser } from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { parseAsInteger } from 'nuqs';
import { ListProfiles } from './list-profiles';
import { getProfileList, getProfileListCount } from '@mixan/db';
import { StickyBelowHeader } from '../layout-sticky-below-header';
import { ProfileList } from './profile-list';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
searchParams: {
f?: string;
cursor?: string;
};
}
const nuqsOptions = {
shallow: false,
};
export default async function Page({
params: { organizationId, projectId },
searchParams: { cursor, f },
}: PageProps) {
await getExists(organizationId, projectId);
const [profiles, count] = await Promise.all([
getProfileList({
projectId,
take: 50,
cursor: parseAsInteger.parse(cursor ?? '') ?? undefined,
filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined,
}),
getProfileListCount({
projectId,
filters: eventQueryFiltersParser.parse(f ?? '') ?? undefined,
}),
getExists(organizationId, projectId),
]);
return (
<PageLayout title="Events" organizationSlug={organizationId}>
<ListProfiles projectId={projectId} />
<PageLayout title="Profiles" organizationSlug={organizationId}>
<StickyBelowHeader className="p-4 flex justify-between">
<OverviewFiltersDrawer
projectId={projectId}
nuqsOptions={nuqsOptions}
mode="events"
/>
<OverviewFiltersButtons
className="p-0 justify-end"
nuqsOptions={nuqsOptions}
/>
</StickyBelowHeader>
<ProfileList data={profiles} count={count} />
</PageLayout>
);
}

View File

@@ -1,30 +1,30 @@
'use client';
import type { RouterOutputs } from '@/app/_trpc/client';
import { ListProperties } from '@/components/events/ListProperties';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
import { useAppParams } from '@/hooks/useAppParams';
import { formatDateTime } from '@/utils/date';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { getProfileName } from '@/utils/getters';
import Link from 'next/link';
type ProfileListItemProps = RouterOutputs['profile']['list'][number];
import type { IServiceProfile } from '@mixan/db';
type ProfileListItemProps = IServiceProfile;
export function ProfileListItem(props: ProfileListItemProps) {
const { id, properties, createdAt } = props;
const params = useAppParams();
const [, setFilter] = useEventQueryFilters({ shallow: false });
const renderContent = () => {
return (
<>
<span>{formatDateTime(createdAt)}</span>
<Link
<KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
<KeyValueSubtle
href={`/${params.organizationId}/${params.projectId}/profiles/${id}`}
className="text-black font-medium hover:underline"
>
See profile
</Link>
name="Details"
value={'See profile'}
/>
</>
);
};
@@ -35,7 +35,29 @@ export function ProfileListItem(props: ProfileListItemProps) {
content={renderContent()}
image={<ProfileAvatar {...props} />}
>
<ListProperties data={properties} className="rounded-none border-none" />
<>
{properties && (
<div className="p-2">
<div className="bg-gradient-to-tr from-slate-100 to-white rounded-md">
<div className="p-4 flex flex-col gap-4">
<div className="font-medium">Properties</div>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{Object.entries(properties)
.filter(([, value]) => !!value)
.map(([key, value]) => (
<KeyValue
onClick={() => setFilter(`properties.${key}`, value)}
key={key}
name={key}
value={value}
/>
))}
</div>
</div>
</div>
</div>
)}
</>
</ExpandableListItem>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { Suspense } from 'react';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination } from '@/components/Pagination';
import { Button } from '@/components/ui/button';
import { useCursor } from '@/hooks/useCursor';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { UsersIcon } from 'lucide-react';
import type { IServiceProfile } from '@mixan/db';
import { ProfileListItem } from './profile-list-item';
interface ProfileListProps {
data: IServiceProfile[];
count: number;
}
export function ProfileList({ data, count }: ProfileListProps) {
const { cursor, setCursor } = useCursor();
const [filters] = useEventQueryFilters();
return (
<Suspense>
<div className="p-4">
{data.length === 0 ? (
<FullPageEmptyState title="No profiles here" icon={UsersIcon}>
{cursor !== 0 ? (
<>
<p>Looks like you have reached the end of the list</p>
<Button
className="mt-4"
variant="outline"
size="sm"
onClick={() => setCursor((p) => Math.max(0, p - 1))}
>
Go back
</Button>
</>
) : (
<>
{filters.length ? (
<p>Could not find any profiles with your filter</p>
) : (
<p>No profiles have been created yet</p>
)}
</>
)}
</FullPageEmptyState>
) : (
<>
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
<div className="flex flex-col gap-4 my-4">
{data.map((item) => (
<ProfileListItem key={item.id} {...item} />
))}
</div>
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</>
)}
</div>
</Suspense>
);
}

View File

@@ -48,7 +48,7 @@ export default async function Page({ params: { id } }: PageProps) {
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFiltersDrawer projectId={projectId} />
<OverviewFiltersDrawer projectId={projectId} mode="events" />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />

View File

@@ -9,6 +9,8 @@ import {
useEventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { useEventValues } from '@/hooks/useEventValues';
import { useProfileProperties } from '@/hooks/useProfileProperties';
import { useProfileValues } from '@/hooks/useProfileValues';
import { XIcon } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
@@ -18,21 +20,25 @@ import type {
IChartEventFilterValue,
} from '@mixan/validation';
interface OverviewFiltersProps {
export interface OverviewFiltersDrawerContentProps {
projectId: string;
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
mode: 'profiles' | 'events';
}
export function OverviewFiltersDrawerContent({
projectId,
nuqsOptions,
enableEventsFilter,
}: OverviewFiltersProps) {
mode,
}: OverviewFiltersDrawerContentProps) {
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
const eventNames = useEventNames(projectId);
const eventProperties = useEventProperties(projectId);
const profileProperties = useProfileProperties(projectId);
const properties = mode === 'events' ? eventProperties : profileProperties;
return (
<div>
@@ -62,7 +68,7 @@ export function OverviewFiltersDrawerContent({
value=""
placeholder="Filter by property"
label="What do you want to filter by?"
items={eventProperties.map((item) => ({
items={properties.map((item) => ({
label: item,
value: item,
}))}
@@ -74,8 +80,15 @@ export function OverviewFiltersDrawerContent({
{filters
.filter((filter) => filter.value[0] !== null)
.map((filter) => {
return (
<FilterOption
return mode === 'events' ? (
<FilterOptionEvent
key={filter.name}
projectId={projectId}
setFilter={setFilter}
{...filter}
/>
) : (
<FilterOptionProfile
key={filter.name}
projectId={projectId}
setFilter={setFilter}
@@ -88,7 +101,7 @@ export function OverviewFiltersDrawerContent({
);
}
export function FilterOption({
export function FilterOptionEvent({
setFilter,
projectId,
...filter
@@ -131,3 +144,43 @@ export function FilterOption({
</div>
);
}
export function FilterOptionProfile({
setFilter,
projectId,
...filter
}: IChartEventFilter & {
projectId: string;
setFilter: (
name: string,
value: IChartEventFilterValue,
operator: IChartEventFilterOperator
) => void;
}) {
const values = useProfileValues(projectId, filter.name);
return (
<div className="flex gap-2 items-center">
<div>{filter.name}</div>
<Combobox
className="flex-1"
onChange={(value) => setFilter(filter.name, value, filter.operator)}
placeholder={'Select a value'}
items={values.map((value) => ({
value,
label: value,
}))}
value={String(filter.value[0] ?? '')}
/>
<Button
size="icon"
variant="ghost"
onClick={() =>
setFilter(filter.name, filter.value[0] ?? '', filter.operator)
}
>
<XIcon />
</Button>
</div>
);
}

View File

@@ -3,21 +3,13 @@
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { FilterIcon } from 'lucide-react';
import type { Options as NuqsOptions } from 'nuqs';
import type { OverviewFiltersDrawerContentProps } from './overview-filters-drawer-content';
import { OverviewFiltersDrawerContent } from './overview-filters-drawer-content';
interface OverviewFiltersDrawerProps {
projectId: string;
nuqsOptions?: NuqsOptions;
enableEventsFilter?: boolean;
}
export function OverviewFiltersDrawer({
projectId,
nuqsOptions,
enableEventsFilter,
}: OverviewFiltersDrawerProps) {
export function OverviewFiltersDrawer(
props: OverviewFiltersDrawerContentProps
) {
return (
<Sheet>
<SheetTrigger asChild>
@@ -26,11 +18,7 @@ export function OverviewFiltersDrawer({
</Button>
</SheetTrigger>
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFiltersDrawerContent
projectId={projectId}
nuqsOptions={nuqsOptions}
enableEventsFilter={enableEventsFilter}
/>
<OverviewFiltersDrawerContent {...props} />
</SheetContent>
</Sheet>
);

View File

@@ -0,0 +1,24 @@
import { cn } from '@/utils/cn';
interface GradientBackgroundProps {
children: React.ReactNode;
className?: string;
}
export function GradientBackground({
children,
className,
...props
}: GradientBackgroundProps) {
return (
<div
className={cn(
'bg-gradient-to-tr from-slate-100 to-white rounded-md',
className
)}
{...props}
>
<div className="p-4 flex flex-col gap-4">{children}</div>
</div>
);
}

View File

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

View File

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

View File

@@ -1,7 +1,14 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import {
createTRPCRouter,
protectedProcedure,
publicProcedure,
} from '@/server/api/trpc';
import { db } from '@/server/db';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
import { z } from 'zod';
import { chQuery, createSqlBuilder } from '@mixan/db';
export const profileRouter = createTRPCRouter({
list: protectedProcedure
.input(
@@ -61,4 +68,56 @@ export const profileRouter = createTRPCRouter({
},
});
}),
properties: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
const events = await chQuery<{ keys: string[] }>(
`SELECT distinct mapKeys(properties) as keys from profiles where project_id = '${projectId}';`
);
const properties = events
.flatMap((event) => event.keys)
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
.map((item) => `properties.${item}`);
properties.push('external_id', 'first_name', 'last_name', 'email');
return pipe(
sort<string>((a, b) => a.length - b.length),
uniq
)(properties);
}),
values: publicProcedure
.input(
z.object({
property: z.string(),
projectId: z.string(),
})
)
.query(async ({ input: { property, projectId } }) => {
const { sb, getSql } = createSqlBuilder();
sb.from = 'profiles';
sb.where.project_id = `project_id = '${projectId}'`;
if (property.startsWith('properties.')) {
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, '${property
.replace(/^properties\./, '')
.replace('.*.', '.%.')}')) as values`;
} else {
sb.select.values = `${property} as values`;
}
const profiles = await chQuery<{ values: string[] }>(getSql());
const values = pipe(
(data: typeof profiles) => map(prop('values'), data),
flatten,
uniq,
sort((a, b) => a.length - b.length)
)(profiles);
return {
values,
};
}),
});

View File

@@ -12,12 +12,12 @@ export async function createSessionEnd(
const sql = `
SELECT * FROM events
WHERE
profile_id = '${payload.profileId}'
device_id = '${payload.deviceId}'
AND created_at >= (
SELECT created_at
FROM events
WHERE
profile_id = '${payload.profileId}'
device_id = '${payload.deviceId}'
AND name = 'session_start'
ORDER BY created_at DESC
LIMIT 1
@@ -33,6 +33,7 @@ export async function createSessionEnd(
`Index: ${index}`,
`Event: ${event.name}`,
`Created: ${event.createdAt.toISOString()}`,
`DeviceId: ${event.deviceId}`,
`Profile: ${event.profileId}`,
`Path: ${event.path}`,
].join('\n')

View File

@@ -1,4 +1,4 @@
import { anyPass, isEmpty, isNil, reject } from 'ramda';
import { anyPass, assocPath, isEmpty, isNil, reject } from 'ramda';
export function toDots(
obj: Record<string, unknown>,
@@ -19,6 +19,16 @@ export function toDots(
}, {});
}
export function toObject(
obj: Record<string, string | undefined>
): Record<string, unknown> {
let result: Record<string, unknown> = {};
Object.entries(obj).forEach(([key, value]) => {
result = assocPath(key.split('.'), value, result);
});
return result;
}
export const strip = reject(anyPass([isEmpty, isNil]));
export function getSafeJson<T>(str: string): T | null {

View File

@@ -1,17 +1,17 @@
import { createHash } from './crypto';
interface GenerateProfileIdOptions {
interface GenerateDeviceIdOptions {
salt: string;
ua: string;
ip: string;
origin: string;
}
export function generateProfileId({
export function generateDeviceId({
salt,
ua,
ip,
origin,
}: GenerateProfileIdOptions) {
}: GenerateDeviceIdOptions) {
return createHash(`${ua}:${ip}:${origin}:${salt}`, 16);
}

View File

@@ -1,6 +1,7 @@
CREATE TABLE openpanel.events (
`id` UUID DEFAULT generateUUIDv4(),
`name` String,
`device_id` String,
`profile_id` String,
`project_id` String,
`path` String,
@@ -28,7 +29,17 @@ CREATE TABLE openpanel.events (
ORDER BY
(project_id, created_at, profile_id) SETTINGS index_granularity = 8192;
CREATE TABLE test.profiles (
CREATE TABLE openpanel.events_bots (
`project_id` String,
`name` String,
`type` String,
`path` String,
`created_at` DateTime64(3),
) ENGINE MergeTree
ORDER BY
(project_id, created_at) SETTINGS index_granularity = 8192;
CREATE TABLE profiles (
`id` String,
`external_id` String,
`first_name` String,
@@ -38,16 +49,24 @@ CREATE TABLE test.profiles (
`properties` Map(String, String),
`project_id` String,
`created_at` DateTime
) ENGINE = ReplacingMergeTree
) ENGINE = ReplacingMergeTree(created_at)
ORDER BY
(id) SETTINGS index_granularity = 8192;
ALTER TABLE
events
ADD
COLUMN continent String
COLUMN device_id String
AFTER
region;
name;
ALTER TABLE
events DROP COLUMN id;
events DROP COLUMN id;
CREATE TABLE ba (
`id` UUID DEFAULT generateUUIDv4(),
`a` String,
`b` String
) ENGINE MergeTree
ORDER BY
(a, b) SETTINGS index_granularity = 8192;

View File

@@ -15,11 +15,13 @@ import type { EventMeta, Prisma } from '../prisma-client';
import { db } from '../prisma-client';
import { createSqlBuilder } from '../sql-builder';
import { getEventFiltersWhereClause } from './chart.service';
import { getProfileById, getProfiles, upsertProfile } from './profile.service';
import type { IServiceProfile } from './profile.service';
export interface IClickhouseEvent {
id: string;
name: string;
device_id: string;
profile_id: string;
project_id: string;
path: string;
@@ -51,6 +53,7 @@ export function transformEvent(
return {
id: event.id,
name: event.name,
deviceId: event.device_id,
profileId: event.profile_id,
projectId: event.project_id,
properties: event.properties,
@@ -78,6 +81,7 @@ export function transformEvent(
export interface IServiceCreateEventPayload {
id: string;
name: string;
deviceId: string;
profileId: string;
projectId: string;
properties: Record<string, unknown> & {
@@ -121,15 +125,8 @@ export async function getEvents(
): Promise<IServiceCreateEventPayload[]> {
const events = await chQuery<IClickhouseEvent>(sql);
if (options.profile) {
const profileIds = events.map((e) => e.profile_id);
const profiles = await db.profile.findMany({
where: {
id: {
in: profileIds,
},
},
select: options.profile === true ? undefined : options.profile,
});
const ids = events.map((e) => e.profile_id);
const profiles = await getProfiles({ ids });
for (const event of events) {
event.profile = profiles.find((p) => p.id === event.profile_id);
@@ -157,41 +154,38 @@ export async function getEvents(
export async function createEvent(
payload: Omit<IServiceCreateEventPayload, 'id'>
) {
console.log(`create event ${payload.name} for ${payload.profileId}`);
if (!payload.profileId) {
payload.profileId = payload.deviceId;
}
console.log(
`create event ${payload.name} for deviceId: ${payload.deviceId} profileId ${payload.profileId}`
);
if (payload.name === 'session_start') {
const profile = await db.profile.findUnique({
where: {
id: payload.profileId,
const exists = await getProfileById(payload.profileId);
if (!exists) {
const { firstName, lastName } = randomSplitName();
await upsertProfile({
id: payload.profileId,
projectId: payload.projectId,
firstName,
lastName,
properties: {
path: payload.path,
country: payload.country,
city: payload.city,
region: payload.region,
os: payload.os,
os_version: payload.osVersion,
browser: payload.browser,
browser_version: payload.browserVersion,
device: payload.device,
brand: payload.brand,
model: payload.model,
referrer: payload.referrer,
referrer_name: payload.referrerName,
referrer_type: payload.referrerType,
},
});
if (!profile) {
const { firstName, lastName } = randomSplitName();
await db.profile.create({
data: {
id: payload.profileId,
project_id: payload.projectId,
first_name: firstName,
last_name: lastName,
properties: {
country: payload.country ?? '',
city: payload.city ?? '',
region: payload.region ?? '',
os: payload.os ?? '',
os_version: payload.osVersion ?? '',
browser: payload.browser ?? '',
browser_version: payload.browserVersion ?? '',
device: payload.device ?? '',
brand: payload.brand ?? '',
model: payload.model ?? '',
referrer: payload.referrer ?? '',
referrer_name: payload.referrerName ?? '',
referrer_type: payload.referrerType ?? '',
},
},
});
}
}
if (payload.properties.hash === '') {
@@ -201,6 +195,7 @@ export async function createEvent(
const event: IClickhouseEvent = {
id: uuid(),
name: payload.name,
device_id: payload.deviceId,
profile_id: payload.profileId,
project_id: payload.projectId,
properties: toDots(omit(['_path'], payload.properties)),
@@ -245,7 +240,7 @@ export async function createEvent(
};
}
interface GetEventListOptions {
export interface GetEventListOptions {
projectId: string;
profileId?: string;
take: number;
@@ -321,3 +316,38 @@ export async function getEventsCount({
return res[0]?.count ?? 0;
}
interface CreateBotEventPayload {
name: string;
type: string;
projectId: string;
createdAt: Date;
}
export function createBotEvent({
name,
type,
projectId,
createdAt,
}: CreateBotEventPayload) {
return ch.insert({
table: 'events_bots',
values: [
{
name,
type,
project_id: projectId,
created_at: formatClickhouseDate(createdAt),
},
],
});
}
export function getConversionEventNames(projectId: string) {
return db.eventMeta.findMany({
where: {
project_id: projectId,
conversion: true,
},
});
}

View File

@@ -1,16 +1,103 @@
import { db } from '../prisma-client';
import { toDots, toObject } from '@mixan/common';
import type { IChartEventFilter } from '@mixan/validation';
export type IServiceProfile = Awaited<ReturnType<typeof getProfileById>>;
import { ch, chQuery } from '../clickhouse-client';
import { createSqlBuilder } from '../sql-builder';
import { getEventFiltersWhereClause } from './chart.service';
export function getProfileById(id: string) {
return db.profile.findUniqueOrThrow({
where: {
id,
},
});
export async function getProfileById(id: string) {
const [profile] = await chQuery<IClickhouseProfile>(
`SELECT * FROM profiles WHERE id = '${id}' ORDER BY created_at DESC LIMIT 1`
);
if (!profile) {
return null;
}
return transformProfile(profile);
}
export function getProfilesByExternalId(
interface GetProfileListOptions {
projectId: string;
take: number;
cursor?: number;
filters?: IChartEventFilter[];
}
function getProfileSelectFields() {
return [
'id',
'argMax(first_name, created_at) as first_name',
'argMax(last_name, created_at) as last_name',
'argMax(email, created_at) as email',
'argMax(avatar, created_at) as avatar',
'argMax(properties, created_at) as properties',
'argMax(project_id, created_at) as project_id',
'max(created_at) as max_created_at',
].join(', ');
}
interface GetProfilesOptions {
ids: string[];
}
export async function getProfiles({ ids }: GetProfilesOptions) {
if (ids.length === 0) {
return [];
}
const data = await chQuery<IClickhouseProfile>(
`SELECT
${getProfileSelectFields()}
FROM profiles
WHERE id IN (${ids.map((id) => `'${id}'`).join(',')})
GROUP BY id
`
);
return data.map(transformProfile);
}
function getProfileInnerSelect(projectId: string) {
return `(SELECT
${getProfileSelectFields()}
FROM profiles
GROUP BY id
HAVING project_id = '${projectId}')`;
}
export async function getProfileList({
take,
cursor,
projectId,
filters,
}: GetProfileListOptions) {
const { sb, getSql } = createSqlBuilder();
sb.from = getProfileInnerSelect(projectId);
if (filters) {
getEventFiltersWhereClause(sb, filters);
}
sb.limit = take;
sb.offset = (cursor ?? 0) * take;
sb.orderBy.created_at = 'max_created_at DESC';
const data = await chQuery<IClickhouseProfile>(getSql());
return data.map(transformProfile);
}
export async function getProfileListCount({
projectId,
filters,
}: Omit<GetProfileListOptions, 'cursor' | 'take'>) {
const { sb, getSql } = createSqlBuilder();
sb.select.count = 'count(id) as count';
sb.from = getProfileInnerSelect(projectId);
if (filters) {
getEventFiltersWhereClause(sb, filters);
}
const [data] = await chQuery<{ count: number }>(getSql());
return data?.count ?? 0;
}
export async function getProfilesByExternalId(
externalId: string | null,
projectId: string
) {
@@ -18,18 +105,91 @@ export function getProfilesByExternalId(
return [];
}
return db.profile.findMany({
where: {
external_id: externalId,
project_id: projectId,
},
});
const data = await chQuery<IClickhouseProfile>(
`SELECT
${getProfileSelectFields()}
FROM profiles
GROUP BY id
HAVING project_id = '${projectId}' AND external_id = '${externalId}'
`
);
return data.map(transformProfile);
}
export function getProfile(id: string) {
return db.profile.findUniqueOrThrow({
where: {
id,
export type IServiceProfile = Omit<
IClickhouseProfile,
'max_created_at' | 'properties'
> & {
createdAt: Date;
properties: Record<string, unknown>;
};
export interface IClickhouseProfile {
id: string;
first_name: string;
last_name: string;
email: string;
avatar: string;
properties: Record<string, string | undefined>;
project_id: string;
max_created_at: string;
}
export interface IServiceUpsertProfile {
projectId: string;
id: string;
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
properties?: Record<string, unknown>;
}
function transformProfile({
max_created_at,
...profile
}: IClickhouseProfile): IServiceProfile {
return {
...profile,
properties: toObject(profile.properties),
createdAt: new Date(max_created_at),
};
}
export async function upsertProfile({
id,
firstName,
lastName,
email,
avatar,
properties,
projectId,
}: IServiceUpsertProfile) {
const [profile] = await chQuery<IClickhouseProfile>(
`SELECT * FROM profiles WHERE id = '${id}' AND project_id = '${projectId}' ORDER BY created_at DESC LIMIT 1`
);
await ch.insert({
table: 'profiles',
format: 'JSONEachRow',
clickhouse_settings: {
date_time_input_format: 'best_effort',
},
values: [
{
id,
first_name: firstName ?? profile?.first_name ?? '',
last_name: lastName ?? profile?.last_name ?? '',
email: email ?? profile?.email ?? '',
avatar: avatar ?? profile?.avatar ?? '',
properties: toDots({
...(profile?.properties ?? {}),
...(properties ?? {}),
}),
project_id: projectId ?? profile?.project_id ?? '',
created_at: new Date(),
},
],
});
}

View File

@@ -10,7 +10,7 @@ export interface EventsQueuePayloadCreateEvent {
}
export interface EventsQueuePayloadCreateSessionEnd {
type: 'createSessionEnd';
payload: Pick<IServiceCreateEventPayload, 'profileId'>;
payload: Pick<IServiceCreateEventPayload, 'deviceId'>;
}
export type EventsQueuePayload =
| EventsQueuePayloadCreateEvent

View File

@@ -5,9 +5,7 @@ import Constants from 'expo-constants';
import type { MixanOptions } from '@mixan/sdk';
import { Mixan } from '@mixan/sdk';
type MixanNativeOptions = MixanOptions & {
ipUrl?: string;
};
type MixanNativeOptions = MixanOptions
export class MixanNative extends Mixan<MixanNativeOptions> {
constructor(options: MixanNativeOptions) {

110
packages/sdk-next/index.tsx Normal file
View File

@@ -0,0 +1,110 @@
import Script from 'next/script';
import type { MixanEventOptions } from '@mixan/sdk';
import type { MixanWebOptions } from '@mixan/sdk-web';
import type { UpdateProfilePayload } from '@mixan/types';
const CDN_URL = 'http://localhost:3002/op.js';
type OpenpanelMethods =
| 'ctor'
| 'event'
| 'setProfile'
| 'setProfileId'
| 'increment'
| 'decrement'
| 'clear';
declare global {
interface Window {
op: {
q?: [string, ...any[]];
(method: OpenpanelMethods, ...args: any[]): void;
};
}
}
type OpenpanelProviderProps = MixanWebOptions & {
profileId?: string;
cdnUrl?: string;
};
export function OpenpanelProvider({
profileId,
cdnUrl,
...options
}: OpenpanelProviderProps) {
const events: { name: OpenpanelMethods; value: unknown }[] = [
{ name: 'ctor', value: options },
];
if (profileId) {
events.push({ name: 'setProfileId', value: profileId });
}
return (
<>
<Script src={cdnUrl ?? CDN_URL} async defer />
<Script
dangerouslySetInnerHTML={{
__html: `window.op = window.op || function(...args) {(window.op.q = window.op.q || []).push(args)};
${events
.map((event) => {
return `window.op('${event.name}', ${JSON.stringify(event.value)});`;
})
.join('\n')}`,
}}
/>
</>
);
}
interface SetProfileIdProps {
value?: string;
}
export function SetProfileId({ value }: SetProfileIdProps) {
return (
<>
<Script
dangerouslySetInnerHTML={{
__html: `window.op('setProfileId', '${value}');`,
}}
/>
</>
);
}
export function trackEvent(name: string, data?: Record<string, unknown>) {
window.op('event', name, data);
}
export function trackScreenView(data?: Record<string, unknown>) {
trackEvent('screen_view', data);
}
export function setProfile(data?: UpdateProfilePayload) {
window.op('setProfile', data);
}
export function setProfileId(profileId: string) {
window.op('setProfileId', profileId);
}
export function increment(
property: string,
value: number,
options?: MixanEventOptions
) {
window.op('increment', property, value, options);
}
export function decrement(
property: string,
value: number,
options?: MixanEventOptions
) {
window.op('decrement', property, value, options);
}
export function clear() {
window.op('clear');
}

View File

@@ -0,0 +1,36 @@
{
"name": "@mixan/next",
"version": "0.0.1",
"module": "index.ts",
"scripts": {
"build": "rm -rf dist && tsup",
"build-for-openpanel": "pnpm build && cp dist/cdn.global.js ../../apps/public/public/op.js && cp dist/cdn.global.js ../../apps/test/public/op.js",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@mixan/sdk": "workspace:*",
"@mixan/sdk-web": "workspace:*",
"@mixan/types": "workspace:*"
},
"peerDependencies": {
"next": "^13.0.0"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",
"@mixan/prettier-config": "workspace:*",
"@mixan/tsconfig": "workspace:*",
"eslint": "^8.48.0",
"prettier": "^3.0.3",
"tsup": "^7.2.0",
"typescript": "^5.2.2"
},
"eslintConfig": {
"root": true,
"extends": [
"@mixan/eslint-config/base"
]
},
"prettier": "@mixan/prettier-config"
}

View File

@@ -0,0 +1,6 @@
{
"extends": "@mixan/tsconfig/base.json",
"compilerOptions": {
"outDir": "dist"
}
}

View File

@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup';
import config from '@mixan/tsconfig/tsup.config.json' assert { type: 'json' };
export default defineConfig({
...(config as any),
entry: ['index.ts'],
format: ['cjs', 'esm'],
});

View File

@@ -1,13 +1,31 @@
// @ts-nocheck
import { MixanWeb as Openpanel } from './index';
const el = document.currentScript;
if (el) {
window.openpanel = new Openpanel({
url: el?.getAttribute('data-url'),
clientId: el?.getAttribute('data-client-id'),
trackOutgoingLinks: !!el?.getAttribute('data-track-outgoing-links'),
trackScreenViews: !!el?.getAttribute('data-track-screen-views'),
});
declare global {
interface Window {
op: {
q?: [string, ...any[]];
(method: string, ...args: any[]): void;
};
}
}
((window) => {
if (window.op && 'q' in window.op) {
const queue = window.op.q || [];
const op = new Openpanel(queue.shift()[1]);
queue.forEach((item) => {
if (item[0] in op) {
// @ts-expect-error
op[item[0]](...item.slice(1));
}
});
window.op = (t, ...args) => {
// @ts-expect-error
const fn = op[t].bind(op);
if (typeof fn === 'function') {
fn(...args);
}
};
}
})(window);

View File

@@ -1,12 +1,19 @@
import type { MixanOptions } from '@mixan/sdk';
import { Mixan } from '@mixan/sdk';
type MixanWebOptions = MixanOptions & {
export type MixanWebOptions = MixanOptions & {
trackOutgoingLinks?: boolean;
trackScreenViews?: boolean;
trackAttributes?: boolean;
hash?: boolean;
};
function toCamelCase(str: string) {
return str.replace(/([-_][a-z])/gi, ($1) =>
$1.toUpperCase().replace('-', '').replace('_', '')
);
}
export class MixanWeb extends Mixan<MixanWebOptions> {
private lastPath = '';
@@ -25,6 +32,10 @@ export class MixanWeb extends Mixan<MixanWebOptions> {
if (this.options.trackScreenViews) {
this.trackScreenViews();
}
if (this.options.trackAttributes) {
this.trackAttributes();
}
}
}
@@ -87,7 +98,40 @@ export class MixanWeb extends Mixan<MixanWebOptions> {
window.addEventListener('locationchange', () => this.screenView());
}
this.screenView();
// give time for setProfile to be called
setTimeout(() => {
this.screenView();
}, 50);
}
public trackAttributes() {
if (this.isServer()) {
return;
}
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
const btn = target.closest('button');
const achor = target.closest('button');
const element = btn?.getAttribute('data-event')
? btn
: achor?.getAttribute('data-event')
? achor
: null;
if (element) {
const properties: Record<string, unknown> = {};
for (const attr of element.attributes) {
if (attr.name.startsWith('data-') && attr.name !== 'data-event') {
properties[toCamelCase(attr.name.replace(/^data-/, ''))] =
attr.value;
}
}
const name = element.getAttribute('data-event');
if (name) {
super.event(name, properties);
}
}
});
}
public screenView(properties?: Record<string, unknown>): void {

View File

@@ -10,16 +10,21 @@ export interface MixanOptions {
clientId: string;
clientSecret?: string;
verbose?: boolean;
setProfileId?: (profileId: string) => void;
getProfileId?: () => string | null | undefined;
removeProfileId?: () => void;
setDeviceId?: (deviceId: string) => void;
getDeviceId?: () => string | null | undefined;
removeDeviceId?: () => void;
}
export interface MixanState {
deviceId?: string;
profileId?: string;
properties: Record<string, unknown>;
}
export interface MixanEventOptions {
profileId?: string;
}
function awaitProperties(
properties: Record<string, string | Promise<string | null>>
): Promise<Record<string, string>> {
@@ -55,6 +60,10 @@ function createApi(_url: string) {
...(options ?? {}),
})
.then(async (res) => {
if (res.status === 401) {
return null;
}
if (res.status !== 200 && res.status !== 202) {
return retry(attempt, resolve);
}
@@ -116,9 +125,13 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
this.state.properties = properties ?? {};
}
public setUser(payload: Omit<UpdateProfilePayload, 'profileId'>) {
public setProfileId(profileId: string) {
this.state.profileId = profileId;
}
public setProfile(payload: UpdateProfilePayload) {
this.setProfileId(payload.profileId);
this.api.fetch<UpdateProfilePayload, string>('/profile', {
profileId: this.getProfileId(),
...payload,
properties: {
...this.state.properties,
@@ -127,23 +140,44 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
});
}
public increment(property: string, value: number) {
public increment(
property: string,
value: number,
options?: MixanEventOptions
) {
const profileId = options?.profileId ?? this.state.profileId;
if (!profileId) {
return console.log('No profile id');
}
this.api.fetch<IncrementProfilePayload, string>('/profile/increment', {
profileId,
property,
value,
profileId: this.getProfileId(),
});
}
public decrement(property: string, value: number) {
public decrement(
property: string,
value: number,
options?: MixanEventOptions
) {
const profileId = options?.profileId ?? this.state.profileId;
if (!profileId) {
return console.log('No profile id');
}
this.api.fetch<DecrementProfilePayload, string>('/profile/decrement', {
profileId,
property,
value,
profileId: this.getProfileId(),
});
}
public event(name: string, properties?: Record<string, unknown>) {
public event(
name: string,
properties?: Record<string, unknown> & MixanEventOptions
) {
const profileId = properties?.profileId ?? this.state.profileId;
delete properties?.profileId;
this.api
.fetch<PostEventPayload, string>('/event', {
name,
@@ -152,11 +186,12 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
...(properties ?? {}),
},
timestamp: this.timestamp(),
profileId: this.getProfileId(),
deviceId: this.getDeviceId(),
profileId,
})
.then((profileId) => {
if (this.options.setProfileId && profileId) {
this.options.setProfileId(profileId);
.then((deviceId) => {
if (this.options.setDeviceId && deviceId) {
this.options.setDeviceId(deviceId);
}
});
}
@@ -169,9 +204,10 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
}
public clear() {
this.state.profileId = undefined;
if (this.options.removeProfileId) {
this.options.removeProfileId();
this.state.properties = {};
this.state.deviceId = undefined;
if (this.options.removeDeviceId) {
this.options.removeDeviceId();
}
}
@@ -181,11 +217,11 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
return new Date().toISOString();
}
private getProfileId() {
if (this.state.profileId) {
return this.state.profileId;
} else if (this.options.getProfileId) {
this.state.profileId = this.options.getProfileId() || undefined;
private getDeviceId() {
if (this.state.deviceId) {
return this.state.deviceId;
} else if (this.options.getDeviceId) {
this.state.deviceId = this.options.getDeviceId() || undefined;
}
}
}

View File

@@ -137,6 +137,7 @@ export interface MixanResponse<T> {
export interface PostEventPayload {
name: string;
timestamp: string;
deviceId?: string;
profileId?: string;
properties?: Record<string, unknown> & {
title?: string | undefined;
@@ -146,17 +147,16 @@ export interface PostEventPayload {
}
export interface UpdateProfilePayload {
profileId?: string;
id?: string;
first_name?: string;
last_name?: string;
profileId: string;
firstName?: string;
lastName?: string;
email?: string;
avatar?: string;
properties?: MixanJson;
}
export interface IncrementProfilePayload {
profileId?: string;
profileId: string;
property: string;
value: number;
}

221
pnpm-lock.yaml generated
View File

@@ -120,6 +120,9 @@ importers:
'@mixan/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
'@tailwindcss/typography':
specifier: ^0.5.10
version: 0.5.10(tailwindcss@3.4.1)
'@types/node':
specifier: ^18.16.0
version: 18.19.17
@@ -235,6 +238,9 @@ importers:
apps/test:
dependencies:
'@mixan-test/next':
specifier: workspace:@mixan/next@*
version: link:../../packages/sdk-next
'@mixan-test/sdk':
specifier: workspace:@mixan/sdk@*
version: link:../../packages/sdk
@@ -242,8 +248,8 @@ importers:
specifier: workspace:@mixan/sdk-web@*
version: link:../../packages/sdk-web
next:
specifier: '13.4'
version: 13.4.19(react-dom@18.2.0)(react@18.2.0)
specifier: ~14.1.0
version: 14.1.0(react-dom@18.2.0)(react@18.2.0)
react:
specifier: 18.2.0
version: 18.2.0
@@ -703,7 +709,7 @@ importers:
dependencies:
'@clerk/nextjs':
specifier: ^4.29.7
version: 4.29.7(next@14.0.4)(react-dom@18.2.0)(react@18.2.0)
version: 4.29.7(next@14.1.0)(react-dom@18.2.0)(react@18.2.0)
'@clickhouse/client':
specifier: ^0.2.9
version: 0.2.9
@@ -899,6 +905,43 @@ importers:
specifier: ^5.2.2
version: 5.3.3
packages/sdk-next:
dependencies:
'@mixan/sdk':
specifier: workspace:*
version: link:../sdk
'@mixan/sdk-web':
specifier: workspace:*
version: link:../sdk-web
'@mixan/types':
specifier: workspace:*
version: link:../types
next:
specifier: ^13.0.0
version: 13.4.19(react-dom@18.2.0)(react@18.2.0)
devDependencies:
'@mixan/eslint-config':
specifier: workspace:*
version: link:../../tooling/eslint
'@mixan/prettier-config':
specifier: workspace:*
version: link:../../tooling/prettier
'@mixan/tsconfig':
specifier: workspace:*
version: link:../../tooling/typescript
eslint:
specifier: ^8.48.0
version: 8.56.0
prettier:
specifier: ^3.0.3
version: 3.2.5
tsup:
specifier: ^7.2.0
version: 7.3.0(typescript@5.3.3)
typescript:
specifier: ^5.2.2
version: 5.3.3
packages/sdk-web:
dependencies:
'@mixan/sdk':
@@ -2708,6 +2751,26 @@ packages:
tslib: 2.4.1
dev: false
/@clerk/nextjs@4.29.7(next@14.1.0)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-tPvIp4GXCsjcKankLRpPPQGDWmpmlB2tm+p656/OUUmzPMeDnk5Euc86HjSk+5C9BAHVatrveRth6fHa4yzNhQ==}
engines: {node: '>=14'}
peerDependencies:
next: '>=10'
react: ^17.0.2 || ^18.0.0-0
react-dom: ^17.0.2 || ^18.0.0-0
dependencies:
'@clerk/backend': 0.38.1(react@18.2.0)
'@clerk/clerk-react': 4.30.5(react@18.2.0)
'@clerk/clerk-sdk-node': 4.13.9(react@18.2.0)
'@clerk/shared': 1.3.1(react@18.2.0)
'@clerk/types': 3.62.0
next: 14.1.0(react-dom@18.2.0)(react@18.2.0)
path-to-regexp: 6.2.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
tslib: 2.4.1
dev: false
/@clerk/shared@1.3.1(react@18.2.0):
resolution: {integrity: sha512-nzv4+uA90I/eQp55zfK9a1Po9VgCYlzlNhuZnKqyRsPyJ38l4gpIf3B3qSHHdN0+MTx9cWGFrik1CnpftdOBXQ==}
peerDependencies:
@@ -3938,6 +4001,10 @@ packages:
resolution: {integrity: sha512-irQnbMLbUNQpP1wcE5NstJtbuA/69kRfzBrpAD7Gsn8zm/CY6YQYc3HQBz8QPxwISG26tIm5afvvVbu508oBeQ==}
dev: false
/@next/env@14.1.0:
resolution: {integrity: sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==}
dev: false
/@next/eslint-plugin-next@14.1.0:
resolution: {integrity: sha512-x4FavbNEeXx/baD/zC/SdrvkjSby8nBn8KcCREqk6UuwvwoAPZmaV8TFCAuo/cpovBRTIY67mHhe86MQQm/68Q==}
dependencies:
@@ -3962,6 +4029,15 @@ packages:
dev: false
optional: true
/@next/swc-darwin-arm64@14.1.0:
resolution: {integrity: sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@next/swc-darwin-x64@13.4.19:
resolution: {integrity: sha512-jyzO6wwYhx6F+7gD8ddZfuqO4TtpJdw3wyOduR4fxTUCm3aLw7YmHGYNjS0xRSYGAkLpBkH1E0RcelyId6lNsw==}
engines: {node: '>= 10'}
@@ -3980,6 +4056,15 @@ packages:
dev: false
optional: true
/@next/swc-darwin-x64@14.1.0:
resolution: {integrity: sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: false
optional: true
/@next/swc-linux-arm64-gnu@13.4.19:
resolution: {integrity: sha512-vdlnIlaAEh6H+G6HrKZB9c2zJKnpPVKnA6LBwjwT2BTjxI7e0Hx30+FoWCgi50e+YO49p6oPOtesP9mXDRiiUg==}
engines: {node: '>= 10'}
@@ -3998,6 +4083,15 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-gnu@14.1.0:
resolution: {integrity: sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@next/swc-linux-arm64-musl@13.4.19:
resolution: {integrity: sha512-aU0HkH2XPgxqrbNRBFb3si9Ahu/CpaR5RPmN2s9GiM9qJCiBBlZtRTiEca+DC+xRPyCThTtWYgxjWHgU7ZkyvA==}
engines: {node: '>= 10'}
@@ -4016,6 +4110,15 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-musl@14.1.0:
resolution: {integrity: sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@next/swc-linux-x64-gnu@13.4.19:
resolution: {integrity: sha512-htwOEagMa/CXNykFFeAHHvMJeqZfNQEoQvHfsA4wgg5QqGNqD5soeCer4oGlCol6NGUxknrQO6VEustcv+Md+g==}
engines: {node: '>= 10'}
@@ -4034,6 +4137,15 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-gnu@14.1.0:
resolution: {integrity: sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@next/swc-linux-x64-musl@13.4.19:
resolution: {integrity: sha512-4Gj4vvtbK1JH8ApWTT214b3GwUh9EKKQjY41hH/t+u55Knxi/0wesMzwQRhppK6Ddalhu0TEttbiJ+wRcoEj5Q==}
engines: {node: '>= 10'}
@@ -4052,6 +4164,15 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-musl@14.1.0:
resolution: {integrity: sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: false
optional: true
/@next/swc-win32-arm64-msvc@13.4.19:
resolution: {integrity: sha512-bUfDevQK4NsIAHXs3/JNgnvEY+LRyneDN788W2NYiRIIzmILjba7LaQTfihuFawZDhRtkYCv3JDC3B4TwnmRJw==}
engines: {node: '>= 10'}
@@ -4070,6 +4191,15 @@ packages:
dev: false
optional: true
/@next/swc-win32-arm64-msvc@14.1.0:
resolution: {integrity: sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@next/swc-win32-ia32-msvc@13.4.19:
resolution: {integrity: sha512-Y5kikILFAr81LYIFaw6j/NrOtmiM4Sf3GtOc0pn50ez2GCkr+oejYuKGcwAwq3jiTKuzF6OF4iT2INPoxRycEA==}
engines: {node: '>= 10'}
@@ -4088,6 +4218,15 @@ packages:
dev: false
optional: true
/@next/swc-win32-ia32-msvc@14.1.0:
resolution: {integrity: sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@next/swc-win32-x64-msvc@13.4.19:
resolution: {integrity: sha512-YzA78jBDXMYiINdPdJJwGgPNT3YqBNNGhsthsDoWHL9p24tEJn9ViQf/ZqTbwSpX/RrkPupLfuuTH2sf73JBAw==}
engines: {node: '>= 10'}
@@ -4106,6 +4245,15 @@ packages:
dev: false
optional: true
/@next/swc-win32-x64-msvc@14.1.0:
resolution: {integrity: sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: false
optional: true
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -5808,6 +5956,18 @@ packages:
zod: 3.22.4
dev: false
/@tailwindcss/typography@0.5.10(tailwindcss@3.4.1):
resolution: {integrity: sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==}
peerDependencies:
tailwindcss: '>=3.0.0 || insiders'
dependencies:
lodash.castarray: 4.4.0
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
postcss-selector-parser: 6.0.10
tailwindcss: 3.4.1
dev: true
/@tanstack/query-core@4.36.1:
resolution: {integrity: sha512-DJSilV5+ytBP1FbFcEJovv4rnnm/CokuVvrBEtW/Va9DvuJ3HksbXUJEpI0aV1KtuL4ZoO9AVE6PyNLzF7tLeA==}
dev: false
@@ -10338,6 +10498,10 @@ packages:
dependencies:
p-locate: 5.0.0
/lodash.castarray@4.4.0:
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
dev: true
/lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
dev: false
@@ -10350,6 +10514,10 @@ packages:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
dev: false
/lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
dev: true
/lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -11211,6 +11379,45 @@ packages:
- babel-plugin-macros
dev: false
/next@14.1.0(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==}
engines: {node: '>=18.17.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
react: ^18.2.0
react-dom: ^18.2.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
sass:
optional: true
dependencies:
'@next/env': 14.1.0
'@swc/helpers': 0.5.2
busboy: 1.6.0
caniuse-lite: 1.0.30001587
graceful-fs: 4.2.11
postcss: 8.4.31
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
styled-jsx: 5.1.1(react@18.2.0)
optionalDependencies:
'@next/swc-darwin-arm64': 14.1.0
'@next/swc-darwin-x64': 14.1.0
'@next/swc-linux-arm64-gnu': 14.1.0
'@next/swc-linux-arm64-musl': 14.1.0
'@next/swc-linux-x64-gnu': 14.1.0
'@next/swc-linux-x64-musl': 14.1.0
'@next/swc-win32-arm64-msvc': 14.1.0
'@next/swc-win32-ia32-msvc': 14.1.0
'@next/swc-win32-x64-msvc': 14.1.0
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
dev: false
/nice-try@1.0.5:
resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==}
dev: false
@@ -11824,6 +12031,14 @@ packages:
postcss: 8.4.35
postcss-selector-parser: 6.0.15
/postcss-selector-parser@6.0.10:
resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==}
engines: {node: '>=4'}
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
dev: true
/postcss-selector-parser@6.0.15:
resolution: {integrity: sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==}
engines: {node: '>=4'}