a looooot
This commit is contained in:
@@ -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",
|
||||
|
||||
62
apps/public/src/app/footer.tsx
Normal file
62
apps/public/src/app/footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
524
apps/public/src/app/privacy/page.tsx
Normal file
524
apps/public/src/app/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
458
apps/public/src/app/terms/page.tsx
Normal file
458
apps/public/src/app/terms/page.tsx
Normal 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
|
||||
"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>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 "the
|
||||
Company", "We", "Us" or "Our"
|
||||
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
|
||||
"Terms") 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>"AS IS" and "AS AVAILABLE" Disclaimer</h2>
|
||||
<p>
|
||||
The Service is provided to You "AS IS" and "AS
|
||||
AVAILABLE" 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
|
||||
"Commercial Item" 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 "terrorist
|
||||
supporting" 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -14,6 +14,6 @@ export function isBot(ua: string) {
|
||||
|
||||
return {
|
||||
name: res.name,
|
||||
type: res.category,
|
||||
type: res.category || 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
1
apps/test/next-env.d.ts
vendored
1
apps/test/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
/**
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
12
apps/test/src/app/app-dir/page.component.tsx
Normal file
12
apps/test/src/app/app-dir/page.component.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
apps/test/src/app/app-dir/page.tsx
Normal file
55
apps/test/src/app/app-dir/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/test/src/app/layout.tsx
Normal file
22
apps/test/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
24
apps/web/src/components/ui/gradient-background.tsx
Normal file
24
apps/web/src/components/ui/gradient-background.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
apps/web/src/hooks/useProfileProperties.ts
Normal file
10
apps/web/src/hooks/useProfileProperties.ts
Normal 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 ?? [];
|
||||
}
|
||||
10
apps/web/src/hooks/useProfileValues.ts
Normal file
10
apps/web/src/hooks/useProfileValues.ts
Normal 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 ?? [];
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface EventsQueuePayloadCreateEvent {
|
||||
}
|
||||
export interface EventsQueuePayloadCreateSessionEnd {
|
||||
type: 'createSessionEnd';
|
||||
payload: Pick<IServiceCreateEventPayload, 'profileId'>;
|
||||
payload: Pick<IServiceCreateEventPayload, 'deviceId'>;
|
||||
}
|
||||
export type EventsQueuePayload =
|
||||
| EventsQueuePayloadCreateEvent
|
||||
|
||||
@@ -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
110
packages/sdk-next/index.tsx
Normal 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');
|
||||
}
|
||||
36
packages/sdk-next/package.json
Normal file
36
packages/sdk-next/package.json
Normal 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"
|
||||
}
|
||||
6
packages/sdk-next/tsconfig.json
Normal file
6
packages/sdk-next/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@mixan/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
9
packages/sdk-next/tsup.config.ts
Normal file
9
packages/sdk-next/tsup.config.ts
Normal 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'],
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
221
pnpm-lock.yaml
generated
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user