From 10da7d3a1da1e862ce91576d8c6d5a9e2e2d51be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Fri, 27 Feb 2026 22:45:21 +0100 Subject: [PATCH] fix: improve onboarding --- .../controllers/oauth-callback.controller.tsx | 28 +- .../components/auth/sign-in-email-form.tsx | 15 +- .../src/components/auth/sign-in-github.tsx | 52 ++-- .../src/components/auth/sign-in-google.tsx | 92 ++++--- apps/start/src/components/forms/tag-input.tsx | 46 ++-- .../src/components/onboarding/connect-app.tsx | 57 ---- .../components/onboarding/connect-backend.tsx | 86 ------ .../src/components/onboarding/connect-web.tsx | 103 ++++---- .../components/onboarding/curl-preview.tsx | 72 ----- .../onboarding/onboarding-verify-listener.tsx | 49 ++-- .../src/components/onboarding/verify-faq.tsx | 190 ++++++++++++++ .../components/organization/billing-usage.tsx | 88 ++++--- .../src/components/organization/billing.tsx | 68 +++-- .../src/components/skeleton-dashboard.tsx | 74 +++--- apps/start/src/components/syntax.tsx | 38 +-- apps/start/src/hooks/use-cookie-store.tsx | 19 +- apps/start/src/modals/index.tsx | 79 +++--- .../src/modals/onboarding-troubleshoot.tsx | 51 ---- apps/start/src/routes/_login.login.tsx | 30 ++- .../_steps.onboarding.$projectId.connect.tsx | 79 ++++-- .../_steps.onboarding.$projectId.verify.tsx | 33 +-- .../src/routes/_steps.onboarding.project.tsx | 245 ++++++++++-------- apps/start/src/routes/_steps.tsx | 32 ++- apps/start/src/styles.css | 24 ++ packages/trpc/src/routers/auth.ts | 24 +- 25 files changed, 868 insertions(+), 806 deletions(-) delete mode 100644 apps/start/src/components/onboarding/connect-app.tsx delete mode 100644 apps/start/src/components/onboarding/connect-backend.tsx delete mode 100644 apps/start/src/components/onboarding/curl-preview.tsx create mode 100644 apps/start/src/components/onboarding/verify-faq.tsx delete mode 100644 apps/start/src/modals/onboarding-troubleshoot.tsx diff --git a/apps/api/src/controllers/oauth-callback.controller.tsx b/apps/api/src/controllers/oauth-callback.controller.tsx index 77e79eca..95b6d398 100644 --- a/apps/api/src/controllers/oauth-callback.controller.tsx +++ b/apps/api/src/controllers/oauth-callback.controller.tsx @@ -1,16 +1,16 @@ -import { LogError } from '@/utils/errors'; import { Arctic, - type OAuth2Tokens, createSession, generateSessionToken, github, google, + type OAuth2Tokens, setSessionTokenCookie, } from '@openpanel/auth'; import { type Account, connectUserToOrganization, db } from '@openpanel/db'; import type { FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; +import { LogError } from '@/utils/errors'; async function getGithubEmail(githubAccessToken: string) { const emailListRequest = new Request('https://api.github.com/user/emails'); @@ -74,10 +74,15 @@ async function handleExistingUser({ setSessionTokenCookie( (...args) => reply.setCookie(...args), sessionToken, - session.expiresAt, + session.expiresAt ); + reply.setCookie('last-auth-provider', providerName, { + maxAge: 60 * 60 * 24 * 365, + path: '/', + sameSite: 'lax', + }); return reply.redirect( - process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!, + process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL! ); } @@ -103,7 +108,7 @@ async function handleNewUser({ existingUser, oauthUser, providerName, - }, + } ); } @@ -138,10 +143,15 @@ async function handleNewUser({ setSessionTokenCookie( (...args) => reply.setCookie(...args), sessionToken, - session.expiresAt, + session.expiresAt ); + reply.setCookie('last-auth-provider', providerName, { + maxAge: 60 * 60 * 24 * 365, + path: '/', + sameSite: 'lax', + }); return reply.redirect( - process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!, + process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL! ); } @@ -219,7 +229,7 @@ interface ValidatedOAuthQuery { async function validateOAuthCallback( req: FastifyRequest, - provider: Provider, + provider: Provider ): Promise { const schema = z.object({ code: z.string(), @@ -353,7 +363,7 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) { function redirectWithError(reply: FastifyReply, error: LogError | unknown) { const url = new URL( - process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!, + process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL! ); url.pathname = '/login'; if (error instanceof LogError) { diff --git a/apps/start/src/components/auth/sign-in-email-form.tsx b/apps/start/src/components/auth/sign-in-email-form.tsx index fe380dc4..12598009 100644 --- a/apps/start/src/components/auth/sign-in-email-form.tsx +++ b/apps/start/src/components/auth/sign-in-email-form.tsx @@ -13,7 +13,7 @@ import { Button } from '../ui/button'; const validator = zSignInEmail; type IForm = z.infer; -export function SignInEmailForm() { +export function SignInEmailForm({ isLastUsed }: { isLastUsed?: boolean }) { const trpc = useTRPC(); const mutation = useMutation( trpc.auth.signInEmail.mutationOptions({ @@ -54,9 +54,16 @@ export function SignInEmailForm() { type="password" className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20" /> - +
+ + {isLastUsed && ( + + Used last time + + )} +
+ + + + {title()} + + {isLastUsed && ( + + Used last time + + )} + ); } diff --git a/apps/start/src/components/auth/sign-in-google.tsx b/apps/start/src/components/auth/sign-in-google.tsx index 42e1f996..2f089d51 100644 --- a/apps/start/src/components/auth/sign-in-google.tsx +++ b/apps/start/src/components/auth/sign-in-google.tsx @@ -1,11 +1,16 @@ -import { useTRPC } from '@/integrations/trpc/react'; import { useMutation } from '@tanstack/react-query'; import { Button } from '../ui/button'; +import { useTRPC } from '@/integrations/trpc/react'; export function SignInGoogle({ type, inviteId, -}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) { + isLastUsed, +}: { + type: 'sign-in' | 'sign-up'; + inviteId?: string; + isLastUsed?: boolean; +}) { const trpc = useTRPC(); const mutation = useMutation( trpc.auth.signInOAuth.mutationOptions({ @@ -14,46 +19,57 @@ export function SignInGoogle({ window.location.href = res.url; } }, - }), + }) ); const title = () => { - if (type === 'sign-in') return 'Sign in with Google'; - if (type === 'sign-up') return 'Sign up with Google'; + if (type === 'sign-in') { + return 'Sign in with Google'; + } + if (type === 'sign-up') { + return 'Sign up with Google'; + } }; return ( - + + + + + + + {title()} + + {isLastUsed && ( + + Used last time + + )} + ); } diff --git a/apps/start/src/components/forms/tag-input.tsx b/apps/start/src/components/forms/tag-input.tsx index 3a99455d..33688bf8 100644 --- a/apps/start/src/components/forms/tag-input.tsx +++ b/apps/start/src/components/forms/tag-input.tsx @@ -1,13 +1,13 @@ // Based on Christin Alares tag input component (https://github.com/christianalares/seventy-seven) -import { Button } from '@/components/ui/button'; -import { cn } from '@/utils/cn'; import { useAnimate } from 'framer-motion'; import { XIcon } from 'lucide-react'; import type { ElementRef } from 'react'; import { useEffect, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/utils/cn'; -type Props = { +interface Props { placeholder: string; value: string[]; error?: string; @@ -15,7 +15,7 @@ type Props = { onChange: (value: string[]) => void; renderTag?: (tag: string) => string; id?: string; -}; +} const TagInput = ({ value: propValue, @@ -49,7 +49,7 @@ const TagInput = ({ e.preventDefault(); const tagAlreadyExists = value.some( - (tag) => tag.toLowerCase() === inputValue.toLowerCase(), + (tag) => tag.toLowerCase() === inputValue.toLowerCase() ); if (inputValue) { @@ -61,7 +61,7 @@ const TagInput = ({ }, { duration: 0.3, - }, + } ); return; } @@ -100,50 +100,50 @@ const TagInput = ({ return (
{value.map((tag, i) => { const isCreating = false; return ( {renderTag ? renderTag(tag) : tag} ); })} setInputValue(e.target.value)} onKeyDown={handleKeyDown} - onBlur={handleBlur} - id={id} + placeholder={`${placeholder} ↵`} + ref={inputRef} + value={inputValue} />
); diff --git a/apps/start/src/components/onboarding/connect-app.tsx b/apps/start/src/components/onboarding/connect-app.tsx deleted file mode 100644 index 8d6f2435..00000000 --- a/apps/start/src/components/onboarding/connect-app.tsx +++ /dev/null @@ -1,57 +0,0 @@ -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 - .filter((framework) => framework.type.includes('app')) - .map((framework) => ( - - ))} -
-

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

-
- ); -}; - -export default ConnectApp; diff --git a/apps/start/src/components/onboarding/connect-backend.tsx b/apps/start/src/components/onboarding/connect-backend.tsx deleted file mode 100644 index cff866c5..00000000 --- a/apps/start/src/components/onboarding/connect-backend.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { pushModal } from '@/modals'; -import { ServerIcon } from 'lucide-react'; - -import Syntax from '@/components/syntax'; -import { useAppContext } from '@/hooks/use-app-context'; -import type { IServiceClient } from '@openpanel/db'; -import { frameworks } from '@openpanel/sdk-info'; - -type Props = { - client: IServiceClient | null; -}; - -const ConnectBackend = ({ client }: Props) => { - const context = useAppContext(); - return ( - <> -
-
-
- - Backend -
-
- Try with a basic curl command -
-
- - -
-
-

- Pick a framework below to get started. -

-
- {frameworks - .filter((framework) => framework.type.includes('backend')) - .map((framework) => ( - - ))} -
-

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

-
- - ); -}; - -export default ConnectBackend; diff --git a/apps/start/src/components/onboarding/connect-web.tsx b/apps/start/src/components/onboarding/connect-web.tsx index 440e155f..e41fc39c 100644 --- a/apps/start/src/components/onboarding/connect-web.tsx +++ b/apps/start/src/components/onboarding/connect-web.tsx @@ -1,71 +1,80 @@ -import { pushModal } from '@/modals'; -import { MonitorIcon } from 'lucide-react'; - -import Syntax from '@/components/syntax'; import type { IServiceClient } from '@openpanel/db'; import { frameworks } from '@openpanel/sdk-info'; +import { CopyIcon, PlugIcon } from 'lucide-react'; +import { Button } from '../ui/button'; +import Syntax from '@/components/syntax'; +import { useAppContext } from '@/hooks/use-app-context'; +import { pushModal } from '@/modals'; +import { clipboard } from '@/utils/clipboard'; -type Props = { +interface Props { client: IServiceClient | null; -}; +} const ConnectWeb = ({ client }: Props) => { - return ( - <> -
-
- - Website -
-
- Paste the script to your website -
- - + const context = useAppContext(); + const code = ` -`} - /> +`; + return ( + <> +
+
+
+ + Quick start +
+
+ +
+
+
-
-

+

+

Or pick a framework below to get started.

- {frameworks - .filter((framework) => framework.type.includes('website')) - .map((framework) => ( - - ))} + {frameworks.map((framework) => ( + + ))}
-

+

Missing a framework?{' '} Let us know! diff --git a/apps/start/src/components/onboarding/curl-preview.tsx b/apps/start/src/components/onboarding/curl-preview.tsx deleted file mode 100644 index c2b3de0b..00000000 --- a/apps/start/src/components/onboarding/curl-preview.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useAppContext } from '@/hooks/use-app-context'; -import { useClientSecret } from '@/hooks/use-client-secret'; -import { clipboard } from '@/utils/clipboard'; -import type { IServiceProjectWithClients } from '@openpanel/db'; -import Syntax from '../syntax'; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from '../ui/accordion'; - -export function CurlPreview({ - project, -}: { project: IServiceProjectWithClients }) { - const context = useAppContext(); - - const [secret] = useClientSecret(); - const client = project.clients[0]; - if (!client) { - return null; - } - - const payload: Record = { - type: 'track', - payload: { - name: 'screen_view', - properties: { - __title: `Testing OpenPanel - ${project.name}`, - __path: `${project.domain}`, - __referrer: `${context.dashboardUrl}`, - }, - }, - }; - - if (project.types.includes('app')) { - payload.payload.properties.__path = '/'; - delete payload.payload.properties.__referrer; - } - - if (project.types.includes('backend')) { - payload.payload.name = 'test_event'; - payload.payload.properties = {}; - } - - const code = `curl -X POST ${context.apiUrl}/track \\ --H "Content-Type: application/json" \\ --H "openpanel-client-id: ${client.id}" \\ --H "openpanel-client-secret: ${secret}" \\ --H "User-Agent: ${typeof window !== 'undefined' ? window.navigator.userAgent : ''}" \\ --d '${JSON.stringify(payload)}'`; - - return ( -

- - - { - clipboard(code, null); - }} - > - Try out the curl command - - - - - - -
- ); -} diff --git a/apps/start/src/components/onboarding/onboarding-verify-listener.tsx b/apps/start/src/components/onboarding/onboarding-verify-listener.tsx index 2a2bb496..538551b0 100644 --- a/apps/start/src/components/onboarding/onboarding-verify-listener.tsx +++ b/apps/start/src/components/onboarding/onboarding-verify-listener.tsx @@ -1,22 +1,20 @@ -import useWS from '@/hooks/use-ws'; -import { pushModal } from '@/modals'; -import { cn } from '@/utils/cn'; -import { timeAgo } from '@/utils/date'; -import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react'; -import { useState } from 'react'; - import type { IServiceClient, IServiceEvent, IServiceProject, } from '@openpanel/db'; +import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react'; +import { useState } from 'react'; +import useWS from '@/hooks/use-ws'; +import { cn } from '@/utils/cn'; +import { timeAgo } from '@/utils/date'; -type Props = { +interface Props { project: IServiceProject; client: IServiceClient | null; events: IServiceEvent[]; onVerified: (verified: boolean) => void; -}; +} const VerifyListener = ({ client, events: _events, onVerified }: Props) => { const [events, setEvents] = useState(_events ?? []); @@ -25,7 +23,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => { (data) => { setEvents((prev) => [...prev, data]); onVerified(true); - }, + } ); const isConnected = events.length > 0; @@ -34,15 +32,15 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => { if (isConnected) { return ( ); } return ( - + ); }; @@ -51,24 +49,24 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
{renderIcon()}
-
+
{isConnected ? 'Success' : 'Waiting for events'}
{isConnected ? (
{events.length > 5 && ( -
+
{' '} {events.length - 5} more events
)} {events.slice(-5).map((event) => ( -
+
{' '} {event.name}{' '} @@ -84,23 +82,6 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => { )}
- -
- You can{' '} - {' '} - if you are having issues connecting your app. -
); }; diff --git a/apps/start/src/components/onboarding/verify-faq.tsx b/apps/start/src/components/onboarding/verify-faq.tsx new file mode 100644 index 00000000..b4f73fcd --- /dev/null +++ b/apps/start/src/components/onboarding/verify-faq.tsx @@ -0,0 +1,190 @@ +import type { IServiceProjectWithClients } from '@openpanel/db'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { GlobeIcon, KeyIcon, UserIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import CopyInput from '../forms/copy-input'; +import { WithLabel } from '../forms/input-with-label'; +import TagInput from '../forms/tag-input'; +import Syntax from '../syntax'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '../ui/accordion'; +import { Alert, AlertDescription, AlertTitle } from '../ui/alert'; +import { useAppContext } from '@/hooks/use-app-context'; +import { useClientSecret } from '@/hooks/use-client-secret'; +import { handleError, useTRPC } from '@/integrations/trpc/react'; + +export function VerifyFaq({ + project, +}: { + project: IServiceProjectWithClients; +}) { + const context = useAppContext(); + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [secret] = useClientSecret(); + + const updateProject = useMutation( + trpc.project.update.mutationOptions({ + onError: handleError, + onSuccess: () => { + queryClient.invalidateQueries( + trpc.project.getProjectWithClients.queryFilter({ + projectId: project.id, + }) + ); + toast.success('Allowed domains updated'); + }, + }) + ); + + const client = project.clients[0]; + if (!client) { + return null; + } + + const handleCorsChange = (newValue: string[]) => { + const normalized = newValue + .map((item: string) => { + const trimmed = item.trim(); + if ( + trimmed.startsWith('http://') || + trimmed.startsWith('https://') || + trimmed === '*' + ) { + return trimmed; + } + return trimmed ? `https://${trimmed}` : trimmed; + }) + .filter(Boolean); + updateProject.mutate({ id: project.id, cors: normalized }); + }; + + const showSecret = secret && secret !== '[CLIENT_SECRET]'; + + const payload: Record = { + type: 'track', + payload: { + name: 'screen_view', + properties: { + __title: `Testing OpenPanel - ${project.name}`, + __path: `${project.domain}`, + __referrer: `${context.dashboardUrl}`, + }, + }, + }; + + if (project.types.includes('app')) { + payload.payload.properties.__path = '/'; + delete payload.payload.properties.__referrer; + } + + if (project.types.includes('backend')) { + payload.payload.name = 'test_event'; + payload.payload.properties = {}; + } + + const code = `curl -X POST ${context.apiUrl}/track \\ +-H "Content-Type: application/json" \\ +-H "openpanel-client-id: ${client.id}" \\ +-H "openpanel-client-secret: ${secret}" \\ +-H "User-Agent: ${typeof window !== 'undefined' ? window.navigator.userAgent : ''}" \\ +-d '${JSON.stringify(payload)}'`; + + return ( +
+ + + + No events received? + + +

+ Don't worry, this happens to everyone. Here are a few things you + can check: +

+
+ + + Ensure client ID is correct + + + For web tracking, the clientId in your snippet + must match this project. Copy it here if needed: + + + + + + + Correct domain configured + + + For websites it's important that the domain is + correctly configured. We authenticate requests based on the + domain. Update allowed domains below: + + + + tag === '*' ? 'Accept events from any domains' : tag + } + value={project.cors ?? []} + /> + + + + + + Wrong client secret + + + For app and backend events you need the correct{' '} + clientSecret. Copy it here if needed. Never use + the client secret in web or client-side code—it would expose + your credentials. + + {showSecret && ( + + )} + + +
+

+ Still have issues? Join our{' '} + + discord channel + {' '} + give us an email at{' '} + + hello@openpanel.dev + {' '} + and we'll help you out. +

+
+
+ + + Personal curl example + + + + + +
+
+ ); +} diff --git a/apps/start/src/components/organization/billing-usage.tsx b/apps/start/src/components/organization/billing-usage.tsx index 5cb7dae1..7addf54d 100644 --- a/apps/start/src/components/organization/billing-usage.tsx +++ b/apps/start/src/components/organization/billing-usage.tsx @@ -1,18 +1,7 @@ -import { - X_AXIS_STYLE_PROPS, - useXAxisProps, - useYAxisProps, -} from '@/components/report-chart/common/axis'; -import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; -import { useNumber } from '@/hooks/use-numer-formatter'; -import { useTRPC } from '@/integrations/trpc/react'; -import { formatDate } from '@/utils/date'; -import { getChartColor } from '@/utils/theme'; import { sum } from '@openpanel/common'; import type { IServiceOrganization } from '@openpanel/db'; import { useQuery } from '@tanstack/react-query'; import { Loader2Icon } from 'lucide-react'; -import { pick } from 'ramda'; import { Bar, BarChart, @@ -23,16 +12,24 @@ import { YAxis, } from 'recharts'; import { BarShapeBlue } from '../charts/common-bar'; +import { + useXAxisProps, + useYAxisProps, +} from '@/components/report-chart/common/axis'; +import { Widget, WidgetBody, WidgetHead } from '@/components/widget'; +import { useNumber } from '@/hooks/use-numer-formatter'; +import { useTRPC } from '@/integrations/trpc/react'; +import { formatDate } from '@/utils/date'; -type Props = { +interface Props { organization: IServiceOrganization; -}; +} function Card({ title, value }: { title: string; value: string }) { return ( -
-
{title}
-
{value}
+
+
{title}
+
{value}
); } @@ -43,18 +40,20 @@ export default function BillingUsage({ organization }: Props) { const usageQuery = useQuery( trpc.subscription.usage.queryOptions({ organizationId: organization.id, - }), + }) ); // Determine interval based on data range - use weekly if more than 30 days const getDataInterval = () => { - if (!usageQuery.data || usageQuery.data.length === 0) return 'day'; + if (!usageQuery.data || usageQuery.data.length === 0) { + return 'day'; + } const dates = usageQuery.data.map((item) => new Date(item.day)); const minDate = new Date(Math.min(...dates.map((d) => d.getTime()))); const maxDate = new Date(Math.max(...dates.map((d) => d.getTime()))); const daysDiff = Math.ceil( - (maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24), + (maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24) ); return daysDiff > 30 ? 'week' : 'day'; @@ -78,7 +77,7 @@ export default function BillingUsage({ organization }: Props) { return wrapper(
-
, +
); } @@ -86,13 +85,16 @@ export default function BillingUsage({ organization }: Props) { return wrapper(
Issues loading usage data -
, +
); } - if (usageQuery.data?.length === 0) { + if ( + usageQuery.data?.length === 0 || + !usageQuery.data?.some((item) => item.count !== 0) + ) { return wrapper( -
No usage data yet
, +
No usage data yet
); } @@ -105,7 +107,9 @@ export default function BillingUsage({ organization }: Props) { // Group daily data into weekly intervals if data spans more than 30 days const processChartData = () => { - if (!usageQuery.data) return []; + if (!usageQuery.data) { + return []; + } if (useWeeklyIntervals) { // Group daily data into weekly intervals @@ -157,7 +161,7 @@ export default function BillingUsage({ organization }: Props) { Math.max( subscriptionPeriodEventsLimit, subscriptionPeriodEventsCount, - ...chartData.map((item) => item.count), + ...chartData.map((item) => item.count) ), ] as [number, number]; @@ -165,7 +169,7 @@ export default function BillingUsage({ organization }: Props) { return wrapper( <> -
+
{organization.hasSubscription ? ( <> @@ -208,7 +212,7 @@ export default function BillingUsage({ organization }: Props) { item.count) ?? []), + sum(usageQuery.data?.map((item) => item.count) ?? []) )} />
@@ -217,12 +221,12 @@ export default function BillingUsage({ organization }: Props) {
{/* Events Chart */}
-

+

{useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'}

-
+
- + } cursor={{ @@ -239,15 +243,15 @@ export default function BillingUsage({ organization }: Props) {
- , + ); } @@ -261,7 +265,7 @@ function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) { return (
-
+
{useWeekly && payload.weekRange ? payload.weekRange : payload?.date @@ -271,10 +275,10 @@ function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
-
+
Events {useWeekly ? 'this week' : 'this day'}
-
+
{number.format(payload.count)}
@@ -293,22 +297,22 @@ function TotalTooltip(props: any) { return (
-
Total Events
+
Total Events
-
Your events count
-
+
Your events count
+
{number.format(payload.count)}
{payload.limit > 0 && (
-
+
-
Your tier limit
-
+
Your tier limit
+
{number.format(payload.limit)}
diff --git a/apps/start/src/components/organization/billing.tsx b/apps/start/src/components/organization/billing.tsx index 6c8fd041..12727618 100644 --- a/apps/start/src/components/organization/billing.tsx +++ b/apps/start/src/components/organization/billing.tsx @@ -1,9 +1,3 @@ -import { Button } from '@/components/ui/button'; -import { useNumber } from '@/hooks/use-numer-formatter'; -import useWS from '@/hooks/use-ws'; -import { useTRPC } from '@/integrations/trpc/react'; -import { pushModal, useOnPushModal } from '@/modals'; -import { formatDate } from '@/utils/date'; import type { IServiceOrganization } from '@openpanel/db'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { differenceInDays } from 'date-fns'; @@ -14,6 +8,12 @@ import { Progress } from '../ui/progress'; import { Widget, WidgetBody, WidgetHead } from '../widget'; import { BillingFaq } from './billing-faq'; import BillingUsage from './billing-usage'; +import { Button } from '@/components/ui/button'; +import { useNumber } from '@/hooks/use-numer-formatter'; +import useWS from '@/hooks/use-ws'; +import { useTRPC } from '@/integrations/trpc/react'; +import { pushModal, useOnPushModal } from '@/modals'; +import { formatDate } from '@/utils/date'; type Props = { organization: IServiceOrganization; @@ -28,13 +28,13 @@ export default function Billing({ organization }: Props) { const productsQuery = useQuery( trpc.subscription.products.queryOptions({ organizationId: organization.id, - }), + }) ); const currentProductQuery = useQuery( trpc.subscription.getCurrent.queryOptions({ organizationId: organization.id, - }), + }) ); const portalMutation = useMutation( @@ -47,7 +47,7 @@ export default function Billing({ organization }: Props) { onError(error) { toast.error(error.message); }, - }), + }) ); useWS(`/live/organization/${organization.id}`, () => { @@ -55,7 +55,7 @@ export default function Billing({ organization }: Props) { }); const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>( - (organization.subscriptionInterval as 'year' | 'month') || 'month', + (organization.subscriptionInterval as 'year' | 'month') || 'month' ); const products = useMemo(() => { @@ -66,7 +66,7 @@ export default function Billing({ organization }: Props) { const currentProduct = currentProductQuery.data ?? null; const currentPrice = currentProduct?.prices.flatMap((p) => - p.type === 'recurring' && p.amountType === 'fixed' ? [p] : [], + p.type === 'recurring' && p.amountType === 'fixed' ? [p] : [] )[0]; const renderStatus = () => { @@ -138,12 +138,12 @@ export default function Billing({ organization }: Props) { }); return ( -
+
{currentProduct && currentPrice ? ( -
{currentProduct.name}
+
{currentProduct.name}
{number.currency(currentPrice.priceAmount / 100)} @@ -157,58 +157,58 @@ export default function Billing({ organization }: Props) { {renderStatus()}
-
+
{number.format(organization.subscriptionPeriodEventsCount)} /{' '} {number.format(Number(currentProduct.metadata.eventsLimit))}
-
+
diff --git a/apps/start/src/components/skeleton-dashboard.tsx b/apps/start/src/components/skeleton-dashboard.tsx index ed07410e..f5b49961 100644 --- a/apps/start/src/components/skeleton-dashboard.tsx +++ b/apps/start/src/components/skeleton-dashboard.tsx @@ -1,17 +1,17 @@ export function SkeletonDashboard() { return ( -
-
+
+
{/* Sidebar Skeleton */} -
+
{/* Logo area */} -
-
-
+
+
+
{/* Navigation items */} -
+
{[ 'Dashboard', 'Analytics', @@ -21,28 +21,28 @@ export function SkeletonDashboard() { 'Projects', ].map((item, i) => (
-
-
+
+
))}
{/* Project section */}
-
+
{['Project Alpha', 'Project Beta', 'Project Gamma'].map( (project, i) => (
-
-
+
+
- ), + ) )}
@@ -51,17 +51,17 @@ export function SkeletonDashboard() {
{/* Header area */}
-
-
+
+
-
-
+
+
{/* Dashboard grid */} -
+
{[ 'Total Users', 'Active Sessions', @@ -71,36 +71,36 @@ export function SkeletonDashboard() { 'Revenue', ].map((metric, i) => (
-
-
-
+
+
+
))}
{/* Chart area */}
-
-
+
+
{['Desktop', 'Mobile', 'Tablet', 'Other'].map((device, i) => (
-
-
-
+
+
+
))}
-
-
+
+
{[ 'John Doe', @@ -110,15 +110,15 @@ export function SkeletonDashboard() { 'Charlie Wilson', ].map((user, i) => (
-
+
-
-
+
+
-
+
))}
diff --git a/apps/start/src/components/syntax.tsx b/apps/start/src/components/syntax.tsx index 2769c157..e24f91f8 100644 --- a/apps/start/src/components/syntax.tsx +++ b/apps/start/src/components/syntax.tsx @@ -1,21 +1,24 @@ -import { clipboard } from '@/utils/clipboard'; -import { cn } from '@/utils/cn'; import { CopyIcon } from 'lucide-react'; import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'; import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash'; import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json'; +import markdown from 'react-syntax-highlighter/dist/cjs/languages/hljs/markdown'; import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript'; import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/vs2015'; +import { clipboard } from '@/utils/clipboard'; +import { cn } from '@/utils/cn'; SyntaxHighlighter.registerLanguage('typescript', ts); SyntaxHighlighter.registerLanguage('json', json); SyntaxHighlighter.registerLanguage('bash', bash); +SyntaxHighlighter.registerLanguage('markdown', markdown); interface SyntaxProps { code: string; className?: string; - language?: 'typescript' | 'bash' | 'json'; + language?: 'typescript' | 'bash' | 'json' | 'markdown'; wrapLines?: boolean; + copyable?: boolean; } export default function Syntax({ @@ -23,23 +26,23 @@ export default function Syntax({ className, language = 'typescript', wrapLines = false, + copyable = true, }: SyntaxProps) { return (
- + {copyable && ( + + )} {code} diff --git a/apps/start/src/hooks/use-cookie-store.tsx b/apps/start/src/hooks/use-cookie-store.tsx index 366bba15..b835678d 100644 --- a/apps/start/src/hooks/use-cookie-store.tsx +++ b/apps/start/src/hooks/use-cookie-store.tsx @@ -1,5 +1,5 @@ import { useRouteContext } from '@tanstack/react-router'; -import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'; +import { createServerFn } from '@tanstack/react-start'; import { getCookies, setCookie } from '@tanstack/react-start/server'; import { pick } from 'ramda'; import { useEffect, useMemo, useRef, useState } from 'react'; @@ -11,6 +11,7 @@ const VALID_COOKIES = [ 'range', 'supporter-prompt-closed', 'feedback-prompt-seen', + 'last-auth-provider', ] as const; const COOKIE_EVENT_NAME = '__cookie-change'; @@ -20,7 +21,7 @@ const setCookieFn = createServerFn({ method: 'POST' }) key: z.enum(VALID_COOKIES), value: z.string(), maxAge: z.number().optional(), - }), + }) ) .handler(({ data: { key, value, maxAge } }) => { if (!VALID_COOKIES.includes(key)) { @@ -37,13 +38,13 @@ const setCookieFn = createServerFn({ method: 'POST' }) // Called in __root.tsx beforeLoad hook to get cookies from the server // And received with useRouteContext in the client export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() => - pick(VALID_COOKIES, getCookies()), + pick(VALID_COOKIES, getCookies()) ); export function useCookieStore( key: (typeof VALID_COOKIES)[number], defaultValue: T, - options?: { maxAge?: number }, + options?: { maxAge?: number } ) { const { cookies } = useRouteContext({ strict: false }); const [value, setValue] = useState((cookies?.[key] ?? defaultValue) as T); @@ -51,7 +52,7 @@ export function useCookieStore( useEffect(() => { const handleCookieChange = ( - event: CustomEvent<{ key: string; value: T; from: string }>, + event: CustomEvent<{ key: string; value: T; from: string }> ) => { if (event.detail.key === key && event.detail.from !== ref.current) { setValue(event.detail.value); @@ -60,12 +61,12 @@ export function useCookieStore( window.addEventListener( COOKIE_EVENT_NAME, - handleCookieChange as EventListener, + handleCookieChange as EventListener ); return () => { window.removeEventListener( COOKIE_EVENT_NAME, - handleCookieChange as EventListener, + handleCookieChange as EventListener ); }; }, [key]); @@ -82,10 +83,10 @@ export function useCookieStore( window.dispatchEvent( new CustomEvent(COOKIE_EVENT_NAME, { detail: { key, value: newValue, from: ref.current }, - }), + }) ); }, ] as const, - [value, key, options?.maxAge], + [value, key, options?.maxAge] ); } diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx index bc835c98..63658f20 100644 --- a/apps/start/src/modals/index.tsx +++ b/apps/start/src/modals/index.tsx @@ -1,9 +1,4 @@ import { createPushModal } from 'pushmodal'; - -import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal'; -import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal'; -import { op } from '@/utils/op'; -import Instructions from './Instructions'; import AddClient from './add-client'; import AddDashboard from './add-dashboard'; import AddImport from './add-import'; @@ -12,8 +7,8 @@ import AddNotificationRule from './add-notification-rule'; import AddProject from './add-project'; import AddReference from './add-reference'; import BillingSuccess from './billing-success'; -import Confirm from './confirm'; import type { ConfirmProps } from './confirm'; +import Confirm from './confirm'; import CreateInvite from './create-invite'; import DateRangerPicker from './date-ranger-picker'; import DateTimePicker from './date-time-picker'; @@ -24,7 +19,7 @@ import EditMember from './edit-member'; import EditReference from './edit-reference'; import EditReport from './edit-report'; import EventDetails from './event-details'; -import OnboardingTroubleshoot from './onboarding-troubleshoot'; +import Instructions from './Instructions'; import OverviewChartDetails from './overview-chart-details'; import OverviewFilters from './overview-filters'; import RequestPasswordReset from './request-reset-password'; @@ -34,40 +29,42 @@ import ShareDashboardModal from './share-dashboard-modal'; import ShareOverviewModal from './share-overview-modal'; import ShareReportModal from './share-report-modal'; import ViewChartUsers from './view-chart-users'; +import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal'; +import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal'; +import { op } from '@/utils/op'; const modals = { - OverviewTopPagesModal: OverviewTopPagesModal, - OverviewTopGenericModal: OverviewTopGenericModal, - RequestPasswordReset: RequestPasswordReset, - EditEvent: EditEvent, - EditMember: EditMember, - EventDetails: EventDetails, - EditClient: EditClient, - AddProject: AddProject, - AddClient: AddClient, - AddImport: AddImport, - Confirm: Confirm, - SaveReport: SaveReport, - AddDashboard: AddDashboard, - EditDashboard: EditDashboard, - EditReport: EditReport, - EditReference: EditReference, - ShareOverviewModal: ShareOverviewModal, - ShareDashboardModal: ShareDashboardModal, - ShareReportModal: ShareReportModal, - AddReference: AddReference, - ViewChartUsers: ViewChartUsers, - Instructions: Instructions, - OnboardingTroubleshoot: OnboardingTroubleshoot, - DateRangerPicker: DateRangerPicker, - DateTimePicker: DateTimePicker, - OverviewChartDetails: OverviewChartDetails, - AddIntegration: AddIntegration, - AddNotificationRule: AddNotificationRule, - OverviewFilters: OverviewFilters, - CreateInvite: CreateInvite, - SelectBillingPlan: SelectBillingPlan, - BillingSuccess: BillingSuccess, + OverviewTopPagesModal, + OverviewTopGenericModal, + RequestPasswordReset, + EditEvent, + EditMember, + EventDetails, + EditClient, + AddProject, + AddClient, + AddImport, + Confirm, + SaveReport, + AddDashboard, + EditDashboard, + EditReport, + EditReference, + ShareOverviewModal, + ShareDashboardModal, + ShareReportModal, + AddReference, + ViewChartUsers, + Instructions, + DateRangerPicker, + DateTimePicker, + OverviewChartDetails, + AddIntegration, + AddNotificationRule, + OverviewFilters, + CreateInvite, + SelectBillingPlan, + BillingSuccess, }; export const { @@ -83,7 +80,9 @@ export const { }); onPushModal('*', (open, props, name) => { - op.screenView(`modal:${name}`, props as Record); + if (open) { + op.screenView(`modal:${name}`, props as Record); + } }); export const showConfirm = (props: ConfirmProps) => pushModal('Confirm', props); diff --git a/apps/start/src/modals/onboarding-troubleshoot.tsx b/apps/start/src/modals/onboarding-troubleshoot.tsx deleted file mode 100644 index 3c533d95..00000000 --- a/apps/start/src/modals/onboarding-troubleshoot.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { GlobeIcon, KeyIcon, UserIcon } from 'lucide-react'; - -import { ModalContent, ModalHeader } from './Modal/Container'; - -export default function OnboardingTroubleshoot() { - return ( - - -
- - - Wrong client ID - - Make sure your clientId is correct - - - - - Wrong domain on web - - For web apps its important that the domain is correctly configured. - We authenticate the requests based on the domain. - - - - - Wrong client secret - - For app and backend events it's important that you have correct{' '} - clientId and clientSecret - - -
-

- Still have issues? Join our{' '} - - discord channel - {' '} - give us an email at{' '} - - hello@openpanel.dev - {' '} - and we'll help you out. -

-
- ); -} diff --git a/apps/start/src/routes/_login.login.tsx b/apps/start/src/routes/_login.login.tsx index 1103d520..fe711c2a 100644 --- a/apps/start/src/routes/_login.login.tsx +++ b/apps/start/src/routes/_login.login.tsx @@ -1,13 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { AlertCircle } from 'lucide-react'; +import { z } from 'zod'; import { Or } from '@/components/auth/or'; import { SignInEmailForm } from '@/components/auth/sign-in-email-form'; import { SignInGithub } from '@/components/auth/sign-in-github'; import { SignInGoogle } from '@/components/auth/sign-in-google'; -import { LogoSquare } from '@/components/logo'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { PAGE_TITLES, createTitle } from '@/utils/title'; -import { createFileRoute, redirect } from '@tanstack/react-router'; -import { AlertCircle } from 'lucide-react'; -import { z } from 'zod'; +import { useCookieStore } from '@/hooks/use-cookie-store'; +import { createTitle, PAGE_TITLES } from '@/utils/title'; export const Route = createFileRoute('/_login/login')({ component: LoginPage, @@ -25,16 +25,20 @@ export const Route = createFileRoute('/_login/login')({ function LoginPage() { const { error, correlationId } = Route.useSearch(); + const [lastProvider] = useCookieStore( + 'last-auth-provider', + null + ); return ( -
+
-

Sign in

+

Sign in

Don't have an account?{' '} Create one today @@ -42,8 +46,8 @@ function LoginPage() {

{error && ( Error @@ -55,7 +59,7 @@ function LoginPage() {

Contact us if you have any issues.{' '} hello[at]openpanel.dev @@ -68,11 +72,11 @@ function LoginPage() { )}

- +
); } diff --git a/apps/start/src/routes/_steps.onboarding.$projectId.connect.tsx b/apps/start/src/routes/_steps.onboarding.$projectId.connect.tsx index 1c9803e8..ced38f35 100644 --- a/apps/start/src/routes/_steps.onboarding.$projectId.connect.tsx +++ b/apps/start/src/routes/_steps.onboarding.$projectId.connect.tsx @@ -1,16 +1,15 @@ import { useQuery } from '@tanstack/react-query'; import { createFileRoute, redirect } from '@tanstack/react-router'; -import { LockIcon, XIcon } from 'lucide-react'; +import { CopyIcon, DownloadIcon, LockIcon, XIcon } from 'lucide-react'; import { ButtonContainer } from '@/components/button-container'; -import CopyInput from '@/components/forms/copy-input'; import { FullPageEmptyState } from '@/components/full-page-empty-state'; import FullPageLoadingState from '@/components/full-page-loading-state'; -import ConnectApp from '@/components/onboarding/connect-app'; -import ConnectBackend from '@/components/onboarding/connect-backend'; import ConnectWeb from '@/components/onboarding/connect-web'; -import { LinkButton } from '@/components/ui/button'; +import Syntax from '@/components/syntax'; +import { Button, LinkButton } from '@/components/ui/button'; import { useClientSecret } from '@/hooks/use-client-secret'; import { useTRPC } from '@/integrations/trpc/react'; +import { clipboard } from '@/utils/clipboard'; import { createEntityTitle, PAGE_TITLES } from '@/utils/title'; export const Route = createFileRoute('/_steps/onboarding/$projectId/connect')({ @@ -19,7 +18,7 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/connect')({ { title: createEntityTitle('Connect data', PAGE_TITLES.ONBOARDING) }, ], }), - beforeLoad: async ({ context }) => { + beforeLoad: ({ context }) => { if (!context.session?.session) { throw redirect({ to: '/onboarding' }); } @@ -54,27 +53,55 @@ function Component() { ); } - return ( -
-
-
- - Credentials -
- - -
-
- {project?.types?.map((type) => { - const Component = { - website: ConnectWeb, - app: ConnectApp, - backend: ConnectBackend, - }[type]; + const credentials = `CLIENT_ID=${client.id}\nCLIENT_SECRET=${secret}`; + const download = () => { + const blob = new Blob([credentials], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'credentials.txt'; + a.click(); + }; - return ; - })} - + return ( +
+
+
+
+
+
+ + Client credentials +
+
+ + +
+
+ +
+
+ +
+
+
({ meta: [{ title: createEntityTitle('Verify', PAGE_TITLES.ONBOARDING) }], }), - beforeLoad: async ({ context }) => { + beforeLoad: ({ context }) => { if (!context.session?.session) { throw redirect({ to: '/onboarding' }); } @@ -61,20 +61,23 @@ function Component() { } return ( -
- { - refetch(); - setIsVerified(true); - }} - project={project} - /> +
+
+
+ { + refetch(); + setIsVerified(true); + }} + project={project} + /> - - - + +
+
+ { + beforeLoad: ({ context }) => { if (!context.session?.session) { throw redirect({ to: '/onboarding' }); } @@ -105,10 +105,18 @@ function Component() { control: form.control, }); + const domain = useWatch({ + name: 'domain', + control: form.control, + }); + + const [showCorsInput, setShowCorsInput] = useState(false); + useEffect(() => { if (!isWebsite) { form.setValue('domain', null); form.setValue('cors', []); + setShowCorsInput(false); } }, [isWebsite, form]); @@ -121,8 +129,11 @@ function Component() { }, [isWebsite, isApp, isBackend]); return ( -
-
+ +
{organizations.length > 0 ? ( @@ -183,115 +195,142 @@ function Component() { )}
-
- ( - + +
+ {[ + { + key: 'website' as const, + label: 'Website', + Icon: MonitorIcon, + active: isWebsite, + }, + { + key: 'app' as const, + label: 'App', + Icon: SmartphoneIcon, + active: isApp, + }, + { + key: 'backend' as const, + label: 'Backend / API', + Icon: ServerIcon, + active: isBackend, + }, + ].map(({ key, label, Icon, active }) => ( + + ))} +
+ {(form.formState.errors.website?.message || + form.formState.errors.app?.message || + form.formState.errors.backend?.message) && ( +

+ At least one type must be selected +

+ )} + +
+ { + const raw = e.target.value.trim(); + if (!raw) { + return; + } - ( - - { - field.onChange( - newValue.map((item) => { - const trimmed = item.trim(); - if ( - trimmed.startsWith('http://') || - trimmed.startsWith('https://') || - trimmed === '*' - ) { - return trimmed; - } - return `https://${trimmed}`; - }) - ); - }} - placeholder="Accept events from these domains" - renderTag={(tag) => - tag === '*' - ? 'Accept events from any domains' - : tag - } - value={field.value ?? []} - /> - - )} - /> -
-
-
- )} - /> - ( - - )} - /> - ( - - )} - /> + {domain && ( + <> + + +
+ ( + + { + field.onChange( + newValue.map((item: string) => { + const trimmed = item.trim(); + if ( + trimmed.startsWith('http://') || + trimmed.startsWith('https://') || + trimmed === '*' + ) { + return trimmed; + } + return `https://${trimmed}`; + }) + ); + }} + placeholder="Accept events from these domains" + renderTag={(tag: string) => + tag === '*' + ? 'Accept events from any domains' + : tag + } + value={field.value ?? []} + /> + + )} + /> +
+
+ + )} +
+
- +