imporve billing more + supporter prompt on self-hosting

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-10 22:35:59 +01:00
parent e9fc9713e4
commit aa0120a79d
10 changed files with 211 additions and 25 deletions

View File

@@ -8,7 +8,7 @@ export function FeedbackButton() {
return (
<Button
variant={'outline'}
className="text-left justify-start"
className="text-left justify-start text-[13px]"
icon={SparklesIcon}
onClick={() => {
op.track('feedback_button_clicked');

View File

@@ -0,0 +1,147 @@
import { Button, LinkButton } from '@/components/ui/button';
import { useAppContext } from '@/hooks/use-app-context';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { AnimatePresence, motion } from 'framer-motion';
import {
AwardIcon,
HeartIcon,
type LucideIcon,
MessageCircleIcon,
RocketIcon,
SparklesIcon,
XIcon,
ZapIcon,
} from 'lucide-react';
const PERKS = [
{
icon: RocketIcon,
text: 'Latest Docker Images',
description: 'Bleeding-edge builds on every commit',
},
{
icon: MessageCircleIcon,
text: 'Prioritized Support',
description: 'Get help faster with priority Discord support',
},
{
icon: SparklesIcon,
text: 'Feature Requests',
description: 'Your ideas get prioritized in our roadmap',
},
{
icon: AwardIcon,
text: 'Exclusive Discord Role',
description: 'Special badge and recognition in our community',
},
{
icon: ZapIcon,
text: 'Early Access',
description: 'Try new features before public release',
},
{
icon: HeartIcon,
text: 'Direct Impact',
description: 'Your support directly funds development',
},
] as const;
function PerkPoint({
icon: Icon,
text,
description,
}: {
icon: LucideIcon;
text: string;
description: string;
}) {
return (
<div className="row gap-4 items-center">
<Icon className="size-4" />
<div className="flex-1 min-w-0 col gap-1.5">
<h3 className="font-medium text-sm">{text}</h3>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
);
}
export default function SupporterPrompt() {
const { isSelfHosted } = useAppContext();
const [supporterPromptClosed, setSupporterPromptClosed] = useCookieStore(
'supporter-prompt-closed',
false,
);
if (!isSelfHosted) {
return null;
}
return (
<AnimatePresence>
{!supporterPromptClosed && (
<motion.div
initial={{ opacity: 0, x: 100, scale: 0.95 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 100, scale: 0.95 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
className="fixed bottom-0 right-0 z-50 p-4 max-w-md"
>
<div className="bg-card border p-6 rounded-lg shadow-lg col gap-4">
<div>
<div className="row items-center justify-between">
<h2 className="text-xl font-semibold">Support OpenPanel</h2>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={() => setSupporterPromptClosed(true)}
>
<XIcon className="size-4" />
</Button>
</div>
<p className="text-sm text-muted-foreground">
Help us build the future of open analytics
</p>
</div>
<div className="col gap-3">
{PERKS.map((perk) => (
<PerkPoint
key={perk.text}
icon={perk.icon}
text={perk.text}
description={perk.description}
/>
))}
</div>
<div className="pt-2">
<LinkButton
className="w-full"
href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV"
>
Become a Supporter
</LinkButton>
<p className="text-xs text-muted-foreground text-center mt-4">
Starting at $20/month Cancel anytime {' '}
<a
href="https://openpanel.dev/supporter"
target="_blank"
rel="noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
Learn more
</a>
</p>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -135,9 +135,12 @@ export function SidebarContainer({
<ProfileToggle />
</div>
{isSelfHosted && (
<div className={cn('text-sm w-full text-left mt-2')}>
Self-hosted instance
</div>
<a
href="https://openpanel.dev/supporter"
className="text-center text-sm w-full mt-2 border rounded p-2 font-medium block hover:underline hover:text-primary outline-none"
>
Self-hosted instance, support us!
</a>
)}
</div>
</div>

View File

@@ -5,12 +5,20 @@ import { pick } from 'ramda';
import { useEffect, useMemo, useRef, useState } from 'react';
import { z } from 'zod';
const VALID_COOKIES = ['ui-theme', 'chartType', 'range'] as const;
const VALID_COOKIES = [
'ui-theme',
'chartType',
'range',
'supporter-prompt-closed',
] as const;
const COOKIE_EVENT_NAME = '__cookie-change';
const setCookieFn = createServerFn({ method: 'POST' })
.inputValidator(z.object({ key: z.enum(VALID_COOKIES), value: z.string() }))
.handler(({ data: { key, value } }) => {
if (!VALID_COOKIES.includes(key)) {
return;
}
setCookie(key, value);
});

View File

@@ -40,7 +40,7 @@ export default function SaveReport({
const queryClient = useQueryClient();
const { organizationId, projectId } = useAppParams();
const searchParams = useSearch({
from: '/_app/$organizationId/$projectId_/reports',
from: '/_app/$organizationId/$projectId/reports',
shouldThrow: false,
});
const dashboardId = searchParams?.dashboardId;

View File

@@ -57,7 +57,7 @@ import { Route as AppOrganizationIdProjectIdSettingsTabsIndexRouteImport } from
import { Route as AppOrganizationIdProjectIdProfilesTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.profiles._tabs.index'
import { Route as AppOrganizationIdProjectIdNotificationsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.notifications._tabs.index'
import { Route as AppOrganizationIdProjectIdEventsTabsIndexRouteImport } from './routes/_app.$organizationId.$projectId.events._tabs.index'
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId_.settings._tabs.imports'
import { Route as AppOrganizationIdProjectIdSettingsTabsImportsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.imports'
import { Route as AppOrganizationIdProjectIdSettingsTabsEventsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.events'
import { Route as AppOrganizationIdProjectIdSettingsTabsDetailsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.details'
import { Route as AppOrganizationIdProjectIdSettingsTabsClientsRouteImport } from './routes/_app.$organizationId.$projectId.settings._tabs.clients'
@@ -391,9 +391,9 @@ const AppOrganizationIdProjectIdEventsTabsIndexRoute =
} as any)
const AppOrganizationIdProjectIdSettingsTabsImportsRoute =
AppOrganizationIdProjectIdSettingsTabsImportsRouteImport.update({
id: '/$projectId_/settings/_tabs/imports',
path: '/$projectId/settings/imports',
getParentRoute: () => AppOrganizationIdRoute,
id: '/imports',
path: '/imports',
getParentRoute: () => AppOrganizationIdProjectIdSettingsTabsRoute,
} as any)
const AppOrganizationIdProjectIdSettingsTabsEventsRoute =
AppOrganizationIdProjectIdSettingsTabsEventsRouteImport.update({
@@ -651,7 +651,7 @@ export interface FileRoutesById {
'/_app/$organizationId/$projectId/settings/_tabs/clients': typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
'/_app/$organizationId/$projectId/settings/_tabs/details': typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
'/_app/$organizationId/$projectId/settings/_tabs/events': typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
'/_app/$organizationId/$projectId_/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/_app/$organizationId/$projectId/settings/_tabs/imports': typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
'/_app/$organizationId/$projectId/events/_tabs/': typeof AppOrganizationIdProjectIdEventsTabsIndexRoute
'/_app/$organizationId/$projectId/notifications/_tabs/': typeof AppOrganizationIdProjectIdNotificationsTabsIndexRoute
'/_app/$organizationId/$projectId/profiles/_tabs/': typeof AppOrganizationIdProjectIdProfilesTabsIndexRoute
@@ -832,7 +832,7 @@ export interface FileRouteTypes {
| '/_app/$organizationId/$projectId/settings/_tabs/clients'
| '/_app/$organizationId/$projectId/settings/_tabs/details'
| '/_app/$organizationId/$projectId/settings/_tabs/events'
| '/_app/$organizationId/$projectId_/settings/_tabs/imports'
| '/_app/$organizationId/$projectId/settings/_tabs/imports'
| '/_app/$organizationId/$projectId/events/_tabs/'
| '/_app/$organizationId/$projectId/notifications/_tabs/'
| '/_app/$organizationId/$projectId/profiles/_tabs/'
@@ -1225,12 +1225,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppOrganizationIdProjectIdEventsTabsIndexRouteImport
parentRoute: typeof AppOrganizationIdProjectIdEventsTabsRoute
}
'/_app/$organizationId/$projectId_/settings/_tabs/imports': {
id: '/_app/$organizationId/$projectId_/settings/_tabs/imports'
path: '/$projectId/settings/imports'
'/_app/$organizationId/$projectId/settings/_tabs/imports': {
id: '/_app/$organizationId/$projectId/settings/_tabs/imports'
path: '/imports'
fullPath: '/$organizationId/$projectId/settings/imports'
preLoaderRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRouteImport
parentRoute: typeof AppOrganizationIdRoute
parentRoute: typeof AppOrganizationIdProjectIdSettingsTabsRoute
}
'/_app/$organizationId/$projectId/settings/_tabs/events': {
id: '/_app/$organizationId/$projectId/settings/_tabs/events'
@@ -1487,6 +1487,7 @@ interface AppOrganizationIdProjectIdSettingsTabsRouteChildren {
AppOrganizationIdProjectIdSettingsTabsClientsRoute: typeof AppOrganizationIdProjectIdSettingsTabsClientsRoute
AppOrganizationIdProjectIdSettingsTabsDetailsRoute: typeof AppOrganizationIdProjectIdSettingsTabsDetailsRoute
AppOrganizationIdProjectIdSettingsTabsEventsRoute: typeof AppOrganizationIdProjectIdSettingsTabsEventsRoute
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
AppOrganizationIdProjectIdSettingsTabsIndexRoute: typeof AppOrganizationIdProjectIdSettingsTabsIndexRoute
}
@@ -1498,6 +1499,8 @@ const AppOrganizationIdProjectIdSettingsTabsRouteChildren: AppOrganizationIdProj
AppOrganizationIdProjectIdSettingsTabsDetailsRoute,
AppOrganizationIdProjectIdSettingsTabsEventsRoute:
AppOrganizationIdProjectIdSettingsTabsEventsRoute,
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
AppOrganizationIdProjectIdSettingsTabsIndexRoute:
AppOrganizationIdProjectIdSettingsTabsIndexRoute,
}
@@ -1655,7 +1658,6 @@ interface AppOrganizationIdRouteChildren {
AppOrganizationIdIndexRoute: typeof AppOrganizationIdIndexRoute
AppOrganizationIdIntegrationsRoute: typeof AppOrganizationIdIntegrationsRouteWithChildren
AppOrganizationIdMembersRoute: typeof AppOrganizationIdMembersRouteWithChildren
AppOrganizationIdProjectIdSettingsTabsImportsRoute: typeof AppOrganizationIdProjectIdSettingsTabsImportsRoute
}
const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
@@ -1666,8 +1668,6 @@ const AppOrganizationIdRouteChildren: AppOrganizationIdRouteChildren = {
AppOrganizationIdIntegrationsRoute:
AppOrganizationIdIntegrationsRouteWithChildren,
AppOrganizationIdMembersRoute: AppOrganizationIdMembersRouteWithChildren,
AppOrganizationIdProjectIdSettingsTabsImportsRoute:
AppOrganizationIdProjectIdSettingsTabsImportsRoute,
}
const AppOrganizationIdRouteWithChildren =

View File

@@ -33,7 +33,7 @@ import {
import { toast } from 'sonner';
export const Route = createFileRoute(
'/_app/$organizationId/$projectId_/settings/_tabs/imports',
'/_app/$organizationId/$projectId/settings/_tabs/imports',
)({
component: ImportsSettings,
});

View File

@@ -1,4 +1,5 @@
import FullPageLoadingState from '@/components/full-page-loading-state';
import SupporterPrompt from '@/components/organization/supporter-prompt';
import { LinkButton } from '@/components/ui/button';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
@@ -136,6 +137,7 @@ function Component() {
</Alert>
)}
<Outlet />
<SupporterPrompt />
</>
);
}

View File

@@ -5,7 +5,11 @@ import { importQueue } from '@openpanel/queue';
import { zCreateImport } from '@openpanel/validation';
import { getProjectAccess } from '../access';
import { TRPCAccessError } from '../errors';
import {
TRPCAccessError,
TRPCBadRequestError,
TRPCNotFoundError,
} from '../errors';
import { createTRPCRouter, protectedProcedure } from '../trpc';
export const importRouter = createTRPCRouter({
@@ -69,6 +73,28 @@ export const importRouter = createTRPCRouter({
);
}
const organization = await db.organization.findFirst({
where: {
projects: {
some: {
id: input.projectId,
},
},
},
});
if (!organization) {
throw TRPCNotFoundError(
'Could not start import, organization not found',
);
}
if (!organization.isActive) {
throw TRPCBadRequestError(
'You cannot start an import without an active subscription!',
);
}
// Create import record
const importRecord = await db.import.create({
data: {

8
pnpm-lock.yaml generated
View File

@@ -1364,7 +1364,7 @@ importers:
packages/sdks/astro:
dependencies:
'@openpanel/web':
specifier: workspace:1.0.1-local
specifier: workspace:1.0.2-local
version: link:../web
devDependencies:
astro:
@@ -1402,10 +1402,10 @@ importers:
packages/sdks/nextjs:
dependencies:
'@openpanel/web':
specifier: workspace:1.0.1-local
specifier: workspace:1.0.2-local
version: link:../web
next:
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0
specifier: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
version: 15.0.3(@opentelemetry/api@1.9.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react:
specifier: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@@ -30999,7 +30999,7 @@ snapshots:
postcss@8.4.31:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1