diff --git a/README.md b/README.md index c6fe3cc6..e300dbf1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ · Sign in · - Discord + Discord · X/Twitter · diff --git a/apps/api/package.json b/apps/api/package.json index f9bbb0c2..e250d1c1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -26,6 +26,7 @@ "request-ip": "^3.3.0", "sharp": "^0.33.2", "sqlstring": "^2.3.3", + "superjson": "^1.13.3", "ua-parser-js": "^1.0.37", "url-metadata": "^4.1.0", "uuid": "^9.0.1" diff --git a/apps/api/src/controllers/live.controller.ts b/apps/api/src/controllers/live.controller.ts index a47565c5..3eeb2f45 100644 --- a/apps/api/src/controllers/live.controller.ts +++ b/apps/api/src/controllers/live.controller.ts @@ -1,10 +1,15 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { escape } from 'sqlstring'; +import superjson from 'superjson'; import type * as WebSocket from 'ws'; -import { getSafeJson } from '@openpanel/common'; +import { getSuperJson } from '@openpanel/common'; import type { IServiceCreateEventPayload } from '@openpanel/db'; -import { getEvents, getLiveVisitors } from '@openpanel/db'; +import { + getEvents, + getLiveVisitors, + transformMinimalEvent, +} from '@openpanel/db'; import { redis, redisPub, redisSub } from '@openpanel/redis'; export function getLiveEventInfo(key: string) { @@ -19,13 +24,14 @@ export async function test( }>, reply: FastifyReply ) { - const [event] = await getEvents( - `SELECT * FROM events WHERE project_id = ${escape(req.params.projectId)} AND name = 'screen_view' LIMIT 1` + const events = await getEvents( + `SELECT * FROM events WHERE project_id = ${escape(req.params.projectId)} AND name = 'screen_view' LIMIT 500` ); + const event = events[Math.floor(Math.random() * events.length)]; if (!event) { return reply.status(404).send('No event found'); } - redisPub.publish('event', JSON.stringify(event)); + redisPub.publish('event', superjson.stringify(event)); redis.set( `live:event:${event.projectId}:${Math.random() * 1000}`, '', @@ -52,7 +58,7 @@ export function wsVisitors( const message = (channel: string, message: string) => { if (channel === 'event') { - const event = getSafeJson(message); + const event = getSuperJson(message); if (event?.projectId === params.projectId) { getLiveVisitors(params.projectId).then((count) => { connection.socket.send(String(count)); @@ -80,7 +86,25 @@ export function wsVisitors( }); } -export function wsEvents( +export function wsEvents(connection: { socket: WebSocket }) { + redisSub.subscribe('event'); + + const message = (channel: string, message: string) => { + const event = getSuperJson(message); + if (event) { + connection.socket.send(superjson.stringify(transformMinimalEvent(event))); + } + }; + + redisSub.on('message', message); + + connection.socket.on('close', () => { + redisSub.unsubscribe('event'); + redisSub.off('message', message); + }); +} + +export function wsProjectEvents( connection: { socket: WebSocket; }, @@ -95,9 +119,9 @@ export function wsEvents( redisSub.subscribe('event'); const message = (channel: string, message: string) => { - const event = getSafeJson(message); + const event = getSuperJson(message); if (event?.projectId === params.projectId) { - connection.socket.send(JSON.stringify(event)); + connection.socket.send(superjson.stringify(transformMinimalEvent(event))); } }; diff --git a/apps/api/src/routes/live.router.ts b/apps/api/src/routes/live.router.ts index b8318688..a9956c5c 100644 --- a/apps/api/src/routes/live.router.ts +++ b/apps/api/src/routes/live.router.ts @@ -17,7 +17,12 @@ const liveRouter: FastifyPluginCallback = (fastify, opts, done) => { { websocket: true }, controller.wsVisitors ); - fastify.get('/events/:projectId', { websocket: true }, controller.wsEvents); + fastify.get('/events', { websocket: true }, controller.wsEvents); + fastify.get( + '/events/:projectId', + { websocket: true }, + controller.wsProjectEvents + ); done(); }); diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 384c5254..362eb366 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -21,6 +21,7 @@ "@openpanel/db": "workspace:^", "@openpanel/nextjs": "0.0.8-beta", "@openpanel/queue": "workspace:^", + "@openpanel/sdk-info": "workspace:^", "@openpanel/validation": "workspace:^", "@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.0.5", @@ -60,7 +61,10 @@ "date-fns": "^3.3.1", "embla-carousel-react": "8.0.0-rc22", "flag-icons": "^7.1.0", + "framer-motion": "^11.0.28", "hamburger-react": "^2.5.0", + "input-otp": "^1.2.4", + "javascript-time-ago": "^2.5.9", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", "lottie-react": "^2.4.0", diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/event-list-item.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/event-list-item.tsx index b23c3456..5ad0b8b7 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/event-list-item.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/events/event-list-item.tsx @@ -1,22 +1,27 @@ 'use client'; import { useState } from 'react'; +import { SerieIcon } from '@/components/report/chart/SerieIcon'; import { Tooltiper } from '@/components/ui/tooltip'; import { useAppParams } from '@/hooks/useAppParams'; import { useNumber } from '@/hooks/useNumerFormatter'; import { cn } from '@/utils/cn'; import Link from 'next/link'; -import type { IServiceCreateEventPayload } from '@openpanel/db'; +import type { + IServiceCreateEventPayload, + IServiceEventMinimal, +} from '@openpanel/db'; import { EventDetails } from './event-details'; import { EventIcon } from './event-icon'; -type EventListItemProps = IServiceCreateEventPayload; +type EventListItemProps = IServiceEventMinimal | IServiceCreateEventPayload; export function EventListItem(props: EventListItemProps) { const { organizationSlug, projectId } = useAppParams(); - const { createdAt, name, path, duration, meta, profile } = props; + const { createdAt, name, path, duration, meta } = props; + const profile = 'profile' in props ? props.profile : null; const [isDetailsOpen, setIsDetailsOpen] = useState(false); const number = useNumber(); @@ -45,28 +50,50 @@ export function EventListItem(props: EventListItemProps) { return null; }; + const isMinimal = 'minimal' in props; + return ( <> - + {!isMinimal && ( + + )} - - - - - ); -} diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx index 26a449a9..9194222f 100644 --- a/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx +++ b/apps/dashboard/src/app/(app)/[organizationSlug]/page.tsx @@ -1,16 +1,15 @@ -import { LogoSquare } from '@/components/logo'; +import { FullPageEmptyState } from '@/components/full-page-empty-state'; +import FullWidthNavbar from '@/components/full-width-navbar'; import { ProjectCard } from '@/components/projects/project-card'; -import { SignOutButton } from '@clerk/nextjs'; +import SignOutButton from '@/components/sign-out-button'; import { notFound, redirect } from 'next/navigation'; import { + getCurrentOrganizations, getCurrentProjects, getOrganizationBySlug, - isWaitlistUserAccepted, } from '@openpanel/db'; -import { CreateProject } from './create-project'; - interface PageProps { params: { organizationSlug: string; @@ -20,41 +19,25 @@ interface PageProps { export default async function Page({ params: { organizationSlug }, }: PageProps) { - const [organization, projects] = await Promise.all([ - getOrganizationBySlug(organizationSlug), + const [organizations, projects] = await Promise.all([ + getCurrentOrganizations(), getCurrentProjects(organizationSlug), ]); - if (!organization) { - return notFound(); - } + const organization = organizations.find( + (org) => org.slug === organizationSlug + ); - if (process.env.BLOCK) { - const isAccepted = await isWaitlistUserAccepted(); - if (!isAccepted) { - return ( -
-
- -

Not quite there yet

-
- We're still working on Openpanel, but we're not quite - there yet. We'll let you know when we're ready to go! -
-
-
- ); - } + if (!organization) { + return ( + + The organization you were looking for could not be found. + + ); } if (projects.length === 0) { - return ( -
-
- -
-
- ); + return redirect('/onboarding'); } if (projects.length === 1 && projects[0]) { @@ -62,12 +45,16 @@ export default async function Page({ } return ( -
- -

Select project

- {projects.map((item) => ( - - ))} +
+ + + +
+

Select project

+ {projects.map((item) => ( + + ))} +
); } diff --git a/apps/dashboard/src/app/(app)/create-organization.tsx b/apps/dashboard/src/app/(app)/create-organization.tsx deleted file mode 100644 index 553f2f81..00000000 --- a/apps/dashboard/src/app/(app)/create-organization.tsx +++ /dev/null @@ -1,150 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import AnimateHeight from '@/components/animate-height'; -import { CreateClientSuccess } from '@/components/clients/create-client-success'; -import { LogoSquare } from '@/components/logo'; -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { Button, buttonVariants } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Switch } from '@/components/ui/switch'; -import { api, handleError } from '@/trpc/client'; -import { cn } from '@/utils/cn'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { SaveIcon, WallpaperIcon } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import type { SubmitHandler } from 'react-hook-form'; -import { useForm } from 'react-hook-form'; -import { z } from 'zod'; - -const validation = z.object({ - organization: z.string().min(3), - project: z.string().min(3), - cors: z.string().url().or(z.literal('')), -}); - -type IForm = z.infer; - -export function CreateOrganization() { - const router = useRouter(); - const [hasDomain, setHasDomain] = useState(true); - const form = useForm({ - resolver: zodResolver(validation), - defaultValues: { - organization: '', - project: '', - cors: '', - }, - }); - const mutation = api.onboarding.organziation.useMutation({ - onError: handleError, - }); - const onSubmit: SubmitHandler = (values) => { - mutation.mutate({ - ...values, - cors: hasDomain ? values.cors : null, - }); - }; - - if (mutation.isSuccess && mutation.data.client) { - return ( -
- -

Nice job!

-
- You're ready to start using our SDK. Save the client ID and - secret (if you have any) -
- -
- - Read docs - - -
-
- ); - } - - return ( -
- -

Welcome to Openpanel

-
- Create your organization below (can be personal or a company) and your - first project. -
- - Free during beta - - Openpanel is free during beta. Check our{' '} - - pricing - {' '} - if you're curious. We'll also have a free tier. - - -
-
- - -
-
- - -
- -
- - - - -
- -
- -
-
-
- ); -} diff --git a/apps/dashboard/src/app/(app)/page.tsx b/apps/dashboard/src/app/(app)/page.tsx index bab92454..892a8d8e 100644 --- a/apps/dashboard/src/app/(app)/page.tsx +++ b/apps/dashboard/src/app/(app)/page.tsx @@ -1,41 +1,13 @@ -// import { CreateOrganization } from '@clerk/nextjs'; - -import { LogoSquare } from '@/components/logo'; import { redirect } from 'next/navigation'; -import { getCurrentOrganizations, isWaitlistUserAccepted } from '@openpanel/db'; - -import { CreateOrganization } from './create-organization'; +import { getCurrentOrganizations } from '@openpanel/db'; export default async function Page() { const organizations = await getCurrentOrganizations(); - if (process.env.BLOCK) { - const isAccepted = await isWaitlistUserAccepted(); - if (!isAccepted) { - return ( -
-
- -

Not quite there yet

-
- We're still working on Openpanel, but we're not quite - there yet. We'll let you know when we're ready to go! -
-
-
- ); - } - } if (organizations.length > 0) { return redirect(`/${organizations[0]?.slug}`); } - return ( -
-
- -
-
- ); + return redirect('/onboarding'); } diff --git a/apps/dashboard/src/app/(auth)/layout.tsx b/apps/dashboard/src/app/(auth)/layout.tsx new file mode 100644 index 00000000..65cc9a42 --- /dev/null +++ b/apps/dashboard/src/app/(auth)/layout.tsx @@ -0,0 +1,22 @@ +import LiveEventsServer from './live-events'; + +type Props = { + children: React.ReactNode; +}; + +const Page = ({ children }: Props) => { + return ( + <> +
+
+
+ +
+
{children}
+
+
+ + ); +}; + +export default Page; diff --git a/apps/dashboard/src/app/(auth)/live-events/index.tsx b/apps/dashboard/src/app/(auth)/live-events/index.tsx new file mode 100644 index 00000000..1e08500f --- /dev/null +++ b/apps/dashboard/src/app/(auth)/live-events/index.tsx @@ -0,0 +1,12 @@ +import { getEvents, transformMinimalEvent } from '@openpanel/db'; + +import LiveEvents from './live-events'; + +const LiveEventsServer = async () => { + const events = await getEvents( + 'SELECT * FROM events ORDER BY created_at LIMIT 30' + ); + return ; +}; + +export default LiveEventsServer; diff --git a/apps/dashboard/src/app/(auth)/live-events/live-events.tsx b/apps/dashboard/src/app/(auth)/live-events/live-events.tsx new file mode 100644 index 00000000..6d06d798 --- /dev/null +++ b/apps/dashboard/src/app/(auth)/live-events/live-events.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useState } from 'react'; +import { EventListItem } from '@/app/(app)/[organizationSlug]/[projectId]/events/event-list-item'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import useWS from '@/hooks/useWS'; +import { AnimatePresence, motion } from 'framer-motion'; + +import type { IServiceEventMinimal } from '@openpanel/db'; + +type Props = { + events: IServiceEventMinimal[]; +}; + +const LiveEvents = ({ events }: Props) => { + const [state, setState] = useState(events ?? []); + useWS('/live/events', (event) => { + setState((p) => [event, ...p].slice(0, 30)); + }); + return ( + +
+ Real time data +
+ at your fingertips +
+ +
+ {state.map((event) => ( + + + + ))} +
+
+
+ ); +}; + +export default LiveEvents; diff --git a/apps/dashboard/src/app/(auth)/login/email-sign-in.tsx b/apps/dashboard/src/app/(auth)/login/email-sign-in.tsx new file mode 100644 index 00000000..4f271edb --- /dev/null +++ b/apps/dashboard/src/app/(auth)/login/email-sign-in.tsx @@ -0,0 +1,89 @@ +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { getClerkError } from '@/utils/clerk-error'; +import { useSignIn } from '@clerk/nextjs'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { captureMessage } from '@sentry/nextjs'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const validator = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +type IForm = z.infer; + +const EmailSignUp = () => { + const router = useRouter(); + const { isLoaded, signIn, setActive } = useSignIn(); + + const form = useForm({ + resolver: zodResolver(validator), + }); + + useEffect(() => { + if (form.formState.errors.email?.message) { + toast.error(`Email: ${form.formState.errors.email?.message}`); + } + }, [form.formState.errors.email?.message]); + + useEffect(() => { + if (form.formState.errors.password?.message) { + toast.error(`Password: ${form.formState.errors.password?.message}`); + } + }, [form.formState.errors.password?.message]); + + return ( +
{ + if (!isLoaded) { + return toast.error('Sign in is not ready yet, please try again.'); + } + + try { + const result = await signIn.create({ + identifier: values.email, + password: values.password, + }); + + if (result.status === 'complete') { + await setActive({ session: result.createdSessionId }); + router.push('/'); + } else { + captureMessage('Sign in failed', { + extra: { + status: result.status, + }, + }); + } + } catch (e) { + const error = getClerkError(e); + if (error?.message) { + toast.error(error.message); + } + } + })} + > + + + +
+ ); +}; + +export default EmailSignUp; diff --git a/apps/dashboard/src/app/(auth)/login/page.client.tsx b/apps/dashboard/src/app/(auth)/login/page.client.tsx new file mode 100644 index 00000000..c896ceca --- /dev/null +++ b/apps/dashboard/src/app/(auth)/login/page.client.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useSignIn } from '@clerk/nextjs'; +import type { OAuthStrategy } from '@clerk/nextjs/dist/types/server'; +import { toast } from 'sonner'; + +import EmailSignUp from './email-sign-in'; + +const PageClient = () => { + const { signIn } = useSignIn(); + + const signInWith = (strategy: OAuthStrategy) => { + if (!signIn) { + return toast.error('Sign in is not available at the moment'); + } + return signIn.authenticateWithRedirect({ + strategy, + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + }); + }; + + return ( +
+
+
Sign in
+
+ + +
+
+
+ or with email +
+
+ +

+ No account?{' '} + + Create one now + +

+
+
+
+ ); +}; + +export default PageClient; diff --git a/apps/dashboard/src/app/(onboarding)/get-started/page.tsx b/apps/dashboard/src/app/(auth)/login/page.tsx similarity index 57% rename from apps/dashboard/src/app/(onboarding)/get-started/page.tsx rename to apps/dashboard/src/app/(auth)/login/page.tsx index 26d8e651..e582888f 100644 --- a/apps/dashboard/src/app/(onboarding)/get-started/page.tsx +++ b/apps/dashboard/src/app/(auth)/login/page.tsx @@ -1,15 +1,15 @@ import { auth } from '@clerk/nextjs'; import { redirect } from 'next/navigation'; -import GetStartedClient from './get-started'; +import PageClient from './page.client'; // Sign up -const GetStarted = () => { +const Page = () => { const session = auth(); if (session.userId) { return redirect('/'); } - return ; + return ; }; -export default GetStarted; +export default Page; diff --git a/apps/dashboard/src/app/(auth)/register/email-sign-up.tsx b/apps/dashboard/src/app/(auth)/register/email-sign-up.tsx new file mode 100644 index 00000000..3d5653f8 --- /dev/null +++ b/apps/dashboard/src/app/(auth)/register/email-sign-up.tsx @@ -0,0 +1,85 @@ +import { useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { pushModal } from '@/modals'; +import { getClerkError } from '@/utils/clerk-error'; +import { useSignUp } from '@clerk/nextjs'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +const validator = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +type IForm = z.infer; + +const EmailSignUp = () => { + const { isLoaded, signUp } = useSignUp(); + + const form = useForm({ + resolver: zodResolver(validator), + }); + + useEffect(() => { + if (form.formState.errors.email?.message) { + toast.error(`Email: ${form.formState.errors.email?.message}`); + } + }, [form.formState.errors.email?.message]); + + useEffect(() => { + if (form.formState.errors.password?.message) { + toast.error(`Password: ${form.formState.errors.password?.message}`); + } + }, [form.formState.errors.password?.message]); + + return ( +
{ + if (!isLoaded) { + return toast.error('Sign up is not ready yet, please try again.'); + } + + try { + await signUp.create({ + emailAddress: values.email, + password: values.password, + }); + + // Send the user an email with the verification code + await signUp.prepareEmailAddressVerification({ + strategy: 'email_code', + }); + + pushModal('VerifyEmail', { + email: values.email, + }); + } catch (e) { + const error = getClerkError(e); + if (error?.message) { + toast.error(error.message); + } + } + })} + > + + + +
+ ); +}; + +export default EmailSignUp; diff --git a/apps/dashboard/src/app/(auth)/register/page.client.tsx b/apps/dashboard/src/app/(auth)/register/page.client.tsx new file mode 100644 index 00000000..ed53e47c --- /dev/null +++ b/apps/dashboard/src/app/(auth)/register/page.client.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useSignIn } from '@clerk/nextjs'; +import type { OAuthStrategy } from '@clerk/nextjs/dist/types/server'; +import { toast } from 'sonner'; + +import EmailSignUp from './email-sign-up'; + +const PageClient = () => { + const { signIn } = useSignIn(); + + const signInWith = (strategy: OAuthStrategy) => { + if (!signIn) { + return toast.error('Sign in is not available at the moment'); + } + return signIn.authenticateWithRedirect({ + strategy, + redirectUrl: '/sso-callback', + redirectUrlComplete: '/', + }); + }; + + return ( +
+
+
Create an account
+
+ + +
+
+
+ or with email +
+
+ +

+ Already have an account?{' '} + + Sign in + +

+
+
+
+ ); +}; + +export default PageClient; diff --git a/apps/dashboard/src/app/(auth)/register/page.tsx b/apps/dashboard/src/app/(auth)/register/page.tsx new file mode 100644 index 00000000..e582888f --- /dev/null +++ b/apps/dashboard/src/app/(auth)/register/page.tsx @@ -0,0 +1,15 @@ +import { auth } from '@clerk/nextjs'; +import { redirect } from 'next/navigation'; + +import PageClient from './page.client'; + +// Sign up +const Page = () => { + const session = auth(); + if (session.userId) { + return redirect('/'); + } + return ; +}; + +export default Page; diff --git a/apps/dashboard/src/app/(onboarding)/sso-callback/page.tsx b/apps/dashboard/src/app/(auth)/sso-callback/page.tsx similarity index 100% rename from apps/dashboard/src/app/(onboarding)/sso-callback/page.tsx rename to apps/dashboard/src/app/(auth)/sso-callback/page.tsx diff --git a/apps/dashboard/src/app/(onboarding)/get-started/get-started.tsx b/apps/dashboard/src/app/(onboarding)/get-started/get-started.tsx deleted file mode 100644 index a29dc96a..00000000 --- a/apps/dashboard/src/app/(onboarding)/get-started/get-started.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'use client'; - -import { Button } from '@/components/ui/button'; -import { SignInButton, useSignIn, useSignUp } from '@clerk/nextjs'; -import type { OAuthStrategy } from '@clerk/nextjs/dist/types/server'; -import { toast } from 'sonner'; - -import OnboardingLayout, { OnboardingDescription } from '../onboarding-layout'; - -const GetStarted = () => { - const { signIn } = useSignIn(); - - const signInWith = (strategy: OAuthStrategy) => { - if (!signIn) { - return toast.error('Sign in is not available at the moment'); - } - return signIn.authenticateWithRedirect({ - strategy, - redirectUrl: '/sso-callback', - redirectUrlComplete: '/onboarding', - }); - }; - - return ( - - Create your account and start taking control of your data. - - } - > -
- - -
-

- Already have an account?{' '} - - Sign in - -

-
- ); -}; - -export default GetStarted; diff --git a/apps/dashboard/src/app/(onboarding)/layout.tsx b/apps/dashboard/src/app/(onboarding)/layout.tsx index 9d8bcd86..749a3f11 100644 --- a/apps/dashboard/src/app/(onboarding)/layout.tsx +++ b/apps/dashboard/src/app/(onboarding)/layout.tsx @@ -1,4 +1,4 @@ -import { Logo } from '@/components/logo'; +import FullWidthNavbar from '@/components/full-width-navbar'; import SkipOnboarding from './skip-onboarding'; import Steps from './steps'; @@ -10,25 +10,24 @@ type Props = { const Page = ({ children }: Props) => { return ( <> -
+
-
-
- - -
-
+ + +
-
+
Welcome to Openpanel
-
Get started
+
+ Get started +
diff --git a/apps/dashboard/src/app/(onboarding)/onboarding-layout.tsx b/apps/dashboard/src/app/(onboarding)/onboarding-layout.tsx index 0257e364..ef4a5cba 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding-layout.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding-layout.tsx @@ -25,7 +25,7 @@ const OnboardingLayout = ({ return (
-

{title}

+

{title}

{description}
diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/connect-app.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/connect-app.tsx new file mode 100644 index 00000000..3ad590e4 --- /dev/null +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/connect-app.tsx @@ -0,0 +1,59 @@ +import { pushModal } from '@/modals'; +import { SmartphoneIcon } from 'lucide-react'; + +import type { IServiceClient } from '@openpanel/db'; +import { frameworks } from '@openpanel/sdk-info'; + +type Props = { + client: IServiceClient | null; +}; + +const ConnectApp = ({ client }: Props) => { + return ( +
+
+ + App +
+

+ Pick a framework below to get started. +

+
+ {frameworks.app.map((framework) => ( + + ))} +
+

+ Missing a framework?{' '} + + Let us know! + +

+
+ ); +}; + +export default ConnectApp; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/connect-backend.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/connect-backend.tsx new file mode 100644 index 00000000..41e150ae --- /dev/null +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/connect-backend.tsx @@ -0,0 +1,59 @@ +import { pushModal } from '@/modals'; +import { ServerIcon } from 'lucide-react'; + +import type { IServiceClient } from '@openpanel/db'; +import { frameworks } from '@openpanel/sdk-info'; + +type Props = { + client: IServiceClient | null; +}; + +const ConnectBackend = ({ client }: Props) => { + return ( +
+
+ + Backend +
+

+ Pick a framework below to get started. +

+
+ {frameworks.backend.map((framework) => ( + + ))} +
+

+ Missing a framework?{' '} + + Let us know! + +

+
+ ); +}; + +export default ConnectBackend; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/connect-web.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/connect-web.tsx new file mode 100644 index 00000000..9b89d0f9 --- /dev/null +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/connect-web.tsx @@ -0,0 +1,59 @@ +import { pushModal } from '@/modals'; +import { MonitorIcon } from 'lucide-react'; + +import type { IServiceClient } from '@openpanel/db'; +import { frameworks } from '@openpanel/sdk-info'; + +type Props = { + client: IServiceClient | null; +}; + +const ConnectWeb = ({ client }: Props) => { + return ( +
+
+ + Website +
+

+ Pick a framework below to get started. +

+
+ {frameworks.website.map((framework) => ( + + ))} +
+

+ Missing a framework?{' '} + + Let us know! + +

+
+ ); +}; + +export default ConnectWeb; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/onboarding-connect.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/onboarding-connect.tsx new file mode 100644 index 00000000..85e1c657 --- /dev/null +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/onboarding-connect.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { ButtonContainer } from '@/components/button-container'; +import { LinkButton } from '@/components/ui/button'; + +import type { IServiceProjectWithClients } from '@openpanel/db'; + +import OnboardingLayout, { + OnboardingDescription, +} from '../../../onboarding-layout'; +import ConnectApp from './connect-app'; +import ConnectBackend from './connect-backend'; +import ConnectWeb from './connect-web'; + +type Props = { + project: IServiceProjectWithClients; +}; + +const Connect = ({ project }: Props) => { + const client = project.clients[0]; + + if (!client) { + return
Hmm, something fishy is going on. Please reload the page.
; + } + + return ( + + Let's connect your data sources to OpenPanel + + } + > + {project.types.map((type) => { + const Component = { + website: ConnectWeb, + app: ConnectApp, + backend: ConnectBackend, + }[type]; + + return ; + })} + +
+ + Next + + + + ); +}; + +export default Connect; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx new file mode 100644 index 00000000..c993d826 --- /dev/null +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/connect/page.tsx @@ -0,0 +1,34 @@ +import { cookies } from 'next/headers'; + +import { getCurrentOrganizations, getProjectWithClients } from '@openpanel/db'; + +import OnboardingConnect from './onboarding-connect'; + +type Props = { + params: { + projectId: string; + }; +}; + +const Connect = async ({ params: { projectId } }: Props) => { + const orgs = await getCurrentOrganizations(); + const organizationSlug = orgs[0]?.slug; + if (!organizationSlug) { + throw new Error('No organization found'); + } + const project = await getProjectWithClients(projectId); + const clientSecret = cookies().get('onboarding_client_secret')?.value ?? null; + + if (!project) { + return
Hmm, something fishy is going on. Please reload the page.
; + } + + // set visible client secret from cookie + if (clientSecret && project.clients[0]) { + project.clients[0].secret = clientSecret; + } + + return ; +}; + +export default Connect; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify-listener.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify-listener.tsx new file mode 100644 index 00000000..bd998616 --- /dev/null +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify-listener.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import useWS from '@/hooks/useWS'; +import { pushModal } from '@/modals'; +import { cn } from '@/utils/cn'; +import { timeAgo } from '@/utils/date'; +import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react'; + +import type { + IServiceClient, + IServiceCreateEventPayload, + IServiceProject, +} from '@openpanel/db'; + +type Props = { + project: IServiceProject; + client: IServiceClient | null; + events: IServiceCreateEventPayload[]; + onVerified: (verified: boolean) => void; +}; + +const VerifyListener = ({ client, events: _events, onVerified }: Props) => { + const [events, setEvents] = useState( + _events ?? [] + ); + useWS( + `/live/events/${client?.projectId}`, + (data) => { + setEvents((prev) => [...prev, data]); + onVerified(true); + } + ); + + const isConnected = events.length > 0; + + const renderBadge = () => { + if (isConnected) { + return Connected; + } + + return Not connected; + }; + const renderIcon = () => { + if (isConnected) { + return ( + + ); + } + + return ( + + ); + }; + + return ( +
+
+ {client?.name} +
+
+ Connection status: {renderBadge()} +
+ +
+ {renderIcon()} +
+
+ {isConnected ? 'Success' : 'Waiting for events'} +
+ {isConnected ? ( +
+ {events.length > 5 && ( +
+ {' '} + {events.length - 5} more events +
+ )} + {events.slice(-5).map((event, index) => ( +
+ {' '} + {event.name}{' '} + + {timeAgo(event.createdAt, 'round')} + +
+ ))} +
+ ) : ( +
+ Verify that your events works before submitting any changes to App + Store/Google Play +
+ )} +
+
+ +
+ You can{' '} + {' '} + if you are having issues connecting your app. +
+
+ ); +}; + +export default VerifyListener; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify.tsx new file mode 100644 index 00000000..ca1fe9dd --- /dev/null +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/onboarding-verify.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { useState } from 'react'; +import { ButtonContainer } from '@/components/button-container'; +import { LinkButton } from '@/components/ui/button'; +import { cn } from '@/utils/cn'; +import Link from 'next/link'; + +import type { + IServiceCreateEventPayload, + IServiceProjectWithClients, +} from '@openpanel/db'; + +import OnboardingLayout, { + OnboardingDescription, +} from '../../../onboarding-layout'; +import VerifyListener from './onboarding-verify-listener'; + +type Props = { + project: IServiceProjectWithClients; + events: IServiceCreateEventPayload[]; +}; + +const Verify = ({ project, events }: Props) => { + const [verified, setVerified] = useState(events.length > 0); + const client = project.clients[0]; + + if (!client) { + return
Hmm, something fishy is going on. Please reload the page.
; + } + + return ( + + Deploy your changes, as soon as you see events here, you're all + set! + + } + > + {/* + Sadly we cant have a verify for each type since we use the same client for all different types (website, app, backend) + + Pros: the user just need to keep track of one client id/secret + Cons: we cant verify each type individually + + Might be a good idea to add a verify for each type in the future, but for now we will just have one verify for all types + + {project.types.map((type) => { + const Component = { + website: VerifyWeb, + app: VerifyApp, + backend: VerifyBackend, + }[type]; + + return ; + })} */} + + + + Back + + +
+ {!verified && ( + + Skip for now + + )} + + + Your dashboard + +
+
+
+ ); +}; + +export default Verify; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx new file mode 100644 index 00000000..4631620f --- /dev/null +++ b/apps/dashboard/src/app/(onboarding)/onboarding/[projectId]/verify/page.tsx @@ -0,0 +1,44 @@ +import { cookies } from 'next/headers'; +import { escape } from 'sqlstring'; + +import { + getCurrentOrganizations, + getEvents, + getProjectWithClients, +} from '@openpanel/db'; + +import OnboardingVerify from './onboarding-verify'; + +type Props = { + params: { + projectId: string; + }; +}; + +const Verify = async ({ params: { projectId } }: Props) => { + const orgs = await getCurrentOrganizations(); + const organizationSlug = orgs[0]?.slug; + if (!organizationSlug) { + throw new Error('No organization found'); + } + const [project, events] = await Promise.all([ + await getProjectWithClients(projectId), + getEvents( + `SELECT * FROM events WHERE project_id = ${escape(projectId)} LIMIT 100` + ), + ]); + const clientSecret = cookies().get('onboarding_client_secret')?.value ?? null; + + if (!project) { + return
Hmm, something fishy is going on. Please reload the page.
; + } + + // set visible client secret from cookie + if (clientSecret && project.clients[0]) { + project.clients[0].secret = clientSecret; + } + + return ; +}; + +export default Verify; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/connect/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/connect/page.tsx deleted file mode 100644 index 1dacfa3c..00000000 --- a/apps/dashboard/src/app/(onboarding)/onboarding/connect/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { ButtonContainer } from '@/components/button-container'; -import { Button, LinkButton } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import type { CheckboxProps } from '@radix-ui/react-checkbox'; -import Link from 'next/link'; - -import OnboardingLayout, { - OnboardingDescription, -} from '../../onboarding-layout'; - -function CheckboxGroup({ - label, - description, - ...props -}: { label: string; description: string } & CheckboxProps) { - const randId = Math.random().toString(36).substring(7); - return ( - - ); -} - -const Connect = () => { - return ( - - Create your account and start taking control of your data. - - } - > -
- - - -
- - - - Back - - - Next - - -
- ); -}; - -export default Connect; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx new file mode 100644 index 00000000..d2d5edc1 --- /dev/null +++ b/apps/dashboard/src/app/(onboarding)/onboarding/onboarding-tracking.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useEffect } from 'react'; +import AnimateHeight from '@/components/animate-height'; +import { ButtonContainer } from '@/components/button-container'; +import { InputWithLabel } from '@/components/forms/input-with-label'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import { api, handleError } from '@/trpc/client'; +import { cn } from '@/utils/cn'; +import { zodResolver } from '@hookform/resolvers/zod'; +import type { LucideIcon } from 'lucide-react'; +import { MonitorIcon, ServerIcon, SmartphoneIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import type { ControllerRenderProps, SubmitHandler } from 'react-hook-form'; +import { Controller, useForm, useWatch } from 'react-hook-form'; +import type { z } from 'zod'; + +import type { IServiceOrganization } from '@openpanel/db'; +import { zOnboardingProject } from '@openpanel/validation'; + +import OnboardingLayout, { OnboardingDescription } from '../onboarding-layout'; + +function CheckboxGroup({ + label, + description, + Icon, + children, + onChange, + value, + disabled, + error, +}: { + label: string; + description: string; + Icon: LucideIcon; + children?: React.ReactNode; + error?: string; +} & ControllerRenderProps) { + const randId = Math.random().toString(36).substring(7); + return ( +
+ + {children} +
+ ); +} + +type IForm = z.infer; + +const Tracking = () => { + const router = useRouter(); + const mutation = api.onboarding.project.useMutation({ + onError: handleError, + onSuccess(res) { + router.push(`/onboarding/${res.projectId}/connect`); + }, + }); + + const form = useForm({ + resolver: zodResolver(zOnboardingProject), + defaultValues: { + organization: '', + project: '', + domain: null, + website: false, + app: false, + backend: false, + }, + }); + + const isWebsite = useWatch({ + name: 'website', + control: form.control, + }); + + const isApp = useWatch({ + name: 'app', + control: form.control, + }); + + const isBackend = useWatch({ + name: 'backend', + control: form.control, + }); + + useEffect(() => { + if (!isWebsite) { + form.setValue('domain', null); + } + }, [isWebsite, form]); + + const onSubmit: SubmitHandler = (values) => { + mutation.mutate(values); + }; + + useEffect(() => { + form.clearErrors(); + }, [isWebsite, isApp, isBackend]); + + return ( +
+ + Let us know what you want to track so we can help you get started. + + } + > +
+ + +
+
+ ( + + +
+ +
+
+
+ )} + /> + ( + + )} + /> + ( + + )} + /> +
+ + +
+ + + + + ); +}; + +export default Tracking; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/page.tsx index 3380fbc0..8acfb12c 100644 --- a/apps/dashboard/src/app/(onboarding)/onboarding/page.tsx +++ b/apps/dashboard/src/app/(onboarding)/onboarding/page.tsx @@ -1,76 +1,7 @@ -import { ButtonContainer } from '@/components/button-container'; -import { LinkButton } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import type { CheckboxProps } from '@radix-ui/react-checkbox'; - -import OnboardingLayout, { OnboardingDescription } from '../onboarding-layout'; - -function CheckboxGroup({ - label, - description, - ...props -}: { label: string; description: string } & CheckboxProps) { - const randId = Math.random().toString(36).substring(7); - return ( - - ); -} +import OnboardingTracking from './onboarding-tracking'; const Tracking = () => { - return ( - - Create your account and start taking control of your data. - - } - > -
- - - -
- - - - Back - - - Next - - -
- ); + return ; }; export default Tracking; diff --git a/apps/dashboard/src/app/(onboarding)/onboarding/verify/page.tsx b/apps/dashboard/src/app/(onboarding)/onboarding/verify/page.tsx deleted file mode 100644 index ee2bafae..00000000 --- a/apps/dashboard/src/app/(onboarding)/onboarding/verify/page.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { ButtonContainer } from '@/components/button-container'; -import { Button, LinkButton } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import type { CheckboxProps } from '@radix-ui/react-checkbox'; -import Link from 'next/link'; - -import OnboardingLayout, { - OnboardingDescription, -} from '../../onboarding-layout'; - -function CheckboxGroup({ - label, - description, - ...props -}: { label: string; description: string } & CheckboxProps) { - const randId = Math.random().toString(36).substring(7); - return ( - - ); -} - -const Tracking = () => { - return ( - - Create your account and start taking control of your data. - - } - > -
- - - -
- - - - Back - - - Your dashboard - - -
- ); -}; - -export default Tracking; diff --git a/apps/dashboard/src/app/(onboarding)/steps.tsx b/apps/dashboard/src/app/(onboarding)/steps.tsx index d964c9f3..ec973cda 100644 --- a/apps/dashboard/src/app/(onboarding)/steps.tsx +++ b/apps/dashboard/src/app/(onboarding)/steps.tsx @@ -1,13 +1,13 @@ 'use client'; import { cn } from '@/utils/cn'; -import { ArrowRightCircleIcon, CheckCheckIcon, Edit2Icon } from 'lucide-react'; +import { CheckCheckIcon } from 'lucide-react'; import { usePathname } from 'next/navigation'; type Step = { name: string; status: 'completed' | 'current' | 'pending'; - href: string; + match: string; }; type Props = { @@ -15,32 +15,32 @@ type Props = { }; function useSteps(path: string) { - console.log('path', path); - const steps: Step[] = [ { name: 'Account creation', status: 'pending', - href: '/get-started', + match: '/sign-up', }, { - name: 'Tracking information', + name: 'General', status: 'pending', - href: '/onboarding', + match: '/onboarding', }, { name: 'Connect your data', status: 'pending', - href: '/onboarding/connect', + match: '/onboarding/(.+)/connect', }, { name: 'Verify', status: 'pending', - href: '/onboarding/verify', + match: '/onboarding/(.+)/verify', }, ]; - const matchIndex = steps.findLastIndex((step) => path.startsWith(step.href)); + const matchIndex = steps.findLastIndex((step) => + path.match(new RegExp(step.match)) + ); return steps.map((step, index) => { if (index < matchIndex) { @@ -87,18 +87,18 @@ const Steps = ({ className }: Props) => {
{step.status === 'current' && ( -
+
)}
{step.status === 'completed' && } diff --git a/apps/dashboard/src/components/forms/input-with-label.tsx b/apps/dashboard/src/components/forms/input-with-label.tsx index e2d91c66..da47ba3e 100644 --- a/apps/dashboard/src/components/forms/input-with-label.tsx +++ b/apps/dashboard/src/components/forms/input-with-label.tsx @@ -1,26 +1,40 @@ import { forwardRef } from 'react'; +import { BanIcon, InfoIcon } from 'lucide-react'; import { Input } from '../ui/input'; import type { InputProps } from '../ui/input'; import { Label } from '../ui/label'; +import { Tooltiper } from '../ui/tooltip'; type InputWithLabelProps = InputProps & { label: string; error?: string | undefined; + info?: string; }; export const InputWithLabel = forwardRef( - ({ label, className, ...props }, ref) => { + ({ label, className, info, ...props }, ref) => { return (
-
-