imporve billing more + supporter prompt on self-hosting
This commit is contained in:
@@ -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');
|
||||
|
||||
147
apps/start/src/components/organization/supporter-prompt.tsx
Normal file
147
apps/start/src/components/organization/supporter-prompt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
8
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user