feat: add product hunt badge widget
This commit is contained in:
@@ -19,6 +19,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
|||||||
import { Route as WidgetTestRouteImport } from './routes/widget/test'
|
import { Route as WidgetTestRouteImport } from './routes/widget/test'
|
||||||
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
|
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
|
||||||
import { Route as WidgetCounterRouteImport } from './routes/widget/counter'
|
import { Route as WidgetCounterRouteImport } from './routes/widget/counter'
|
||||||
|
import { Route as WidgetBadgeRouteImport } from './routes/widget/badge'
|
||||||
import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck'
|
import { Route as ApiHealthcheckRouteImport } from './routes/api/healthcheck'
|
||||||
import { Route as ApiConfigRouteImport } from './routes/api/config'
|
import { Route as ApiConfigRouteImport } from './routes/api/config'
|
||||||
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
|
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
|
||||||
@@ -138,6 +139,11 @@ const WidgetCounterRoute = WidgetCounterRouteImport.update({
|
|||||||
path: '/widget/counter',
|
path: '/widget/counter',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const WidgetBadgeRoute = WidgetBadgeRouteImport.update({
|
||||||
|
id: '/widget/badge',
|
||||||
|
path: '/widget/badge',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
|
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
|
||||||
id: '/api/healthcheck',
|
id: '/api/healthcheck',
|
||||||
path: '/api/healthcheck',
|
path: '/api/healthcheck',
|
||||||
@@ -531,6 +537,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/onboarding': typeof PublicOnboardingRoute
|
'/onboarding': typeof PublicOnboardingRoute
|
||||||
'/api/config': typeof ApiConfigRoute
|
'/api/config': typeof ApiConfigRoute
|
||||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||||
|
'/widget/badge': typeof WidgetBadgeRoute
|
||||||
'/widget/counter': typeof WidgetCounterRoute
|
'/widget/counter': typeof WidgetCounterRoute
|
||||||
'/widget/realtime': typeof WidgetRealtimeRoute
|
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||||
'/widget/test': typeof WidgetTestRoute
|
'/widget/test': typeof WidgetTestRoute
|
||||||
@@ -596,6 +603,7 @@ export interface FileRoutesByTo {
|
|||||||
'/onboarding': typeof PublicOnboardingRoute
|
'/onboarding': typeof PublicOnboardingRoute
|
||||||
'/api/config': typeof ApiConfigRoute
|
'/api/config': typeof ApiConfigRoute
|
||||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||||
|
'/widget/badge': typeof WidgetBadgeRoute
|
||||||
'/widget/counter': typeof WidgetCounterRoute
|
'/widget/counter': typeof WidgetCounterRoute
|
||||||
'/widget/realtime': typeof WidgetRealtimeRoute
|
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||||
'/widget/test': typeof WidgetTestRoute
|
'/widget/test': typeof WidgetTestRoute
|
||||||
@@ -659,6 +667,7 @@ export interface FileRoutesById {
|
|||||||
'/_public/onboarding': typeof PublicOnboardingRoute
|
'/_public/onboarding': typeof PublicOnboardingRoute
|
||||||
'/api/config': typeof ApiConfigRoute
|
'/api/config': typeof ApiConfigRoute
|
||||||
'/api/healthcheck': typeof ApiHealthcheckRoute
|
'/api/healthcheck': typeof ApiHealthcheckRoute
|
||||||
|
'/widget/badge': typeof WidgetBadgeRoute
|
||||||
'/widget/counter': typeof WidgetCounterRoute
|
'/widget/counter': typeof WidgetCounterRoute
|
||||||
'/widget/realtime': typeof WidgetRealtimeRoute
|
'/widget/realtime': typeof WidgetRealtimeRoute
|
||||||
'/widget/test': typeof WidgetTestRoute
|
'/widget/test': typeof WidgetTestRoute
|
||||||
@@ -734,6 +743,7 @@ export interface FileRouteTypes {
|
|||||||
| '/onboarding'
|
| '/onboarding'
|
||||||
| '/api/config'
|
| '/api/config'
|
||||||
| '/api/healthcheck'
|
| '/api/healthcheck'
|
||||||
|
| '/widget/badge'
|
||||||
| '/widget/counter'
|
| '/widget/counter'
|
||||||
| '/widget/realtime'
|
| '/widget/realtime'
|
||||||
| '/widget/test'
|
| '/widget/test'
|
||||||
@@ -799,6 +809,7 @@ export interface FileRouteTypes {
|
|||||||
| '/onboarding'
|
| '/onboarding'
|
||||||
| '/api/config'
|
| '/api/config'
|
||||||
| '/api/healthcheck'
|
| '/api/healthcheck'
|
||||||
|
| '/widget/badge'
|
||||||
| '/widget/counter'
|
| '/widget/counter'
|
||||||
| '/widget/realtime'
|
| '/widget/realtime'
|
||||||
| '/widget/test'
|
| '/widget/test'
|
||||||
@@ -861,6 +872,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_public/onboarding'
|
| '/_public/onboarding'
|
||||||
| '/api/config'
|
| '/api/config'
|
||||||
| '/api/healthcheck'
|
| '/api/healthcheck'
|
||||||
|
| '/widget/badge'
|
||||||
| '/widget/counter'
|
| '/widget/counter'
|
||||||
| '/widget/realtime'
|
| '/widget/realtime'
|
||||||
| '/widget/test'
|
| '/widget/test'
|
||||||
@@ -935,6 +947,7 @@ export interface RootRouteChildren {
|
|||||||
StepsRoute: typeof StepsRouteWithChildren
|
StepsRoute: typeof StepsRouteWithChildren
|
||||||
ApiConfigRoute: typeof ApiConfigRoute
|
ApiConfigRoute: typeof ApiConfigRoute
|
||||||
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
|
||||||
|
WidgetBadgeRoute: typeof WidgetBadgeRoute
|
||||||
WidgetCounterRoute: typeof WidgetCounterRoute
|
WidgetCounterRoute: typeof WidgetCounterRoute
|
||||||
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
|
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
|
||||||
WidgetTestRoute: typeof WidgetTestRoute
|
WidgetTestRoute: typeof WidgetTestRoute
|
||||||
@@ -1001,6 +1014,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof WidgetCounterRouteImport
|
preLoaderRoute: typeof WidgetCounterRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/widget/badge': {
|
||||||
|
id: '/widget/badge'
|
||||||
|
path: '/widget/badge'
|
||||||
|
fullPath: '/widget/badge'
|
||||||
|
preLoaderRoute: typeof WidgetBadgeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/api/healthcheck': {
|
'/api/healthcheck': {
|
||||||
id: '/api/healthcheck'
|
id: '/api/healthcheck'
|
||||||
path: '/api/healthcheck'
|
path: '/api/healthcheck'
|
||||||
@@ -1874,6 +1894,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
StepsRoute: StepsRouteWithChildren,
|
StepsRoute: StepsRouteWithChildren,
|
||||||
ApiConfigRoute: ApiConfigRoute,
|
ApiConfigRoute: ApiConfigRoute,
|
||||||
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
||||||
|
WidgetBadgeRoute: WidgetBadgeRoute,
|
||||||
WidgetCounterRoute: WidgetCounterRoute,
|
WidgetCounterRoute: WidgetCounterRoute,
|
||||||
WidgetRealtimeRoute: WidgetRealtimeRoute,
|
WidgetRealtimeRoute: WidgetRealtimeRoute,
|
||||||
WidgetTestRoute: WidgetTestRoute,
|
WidgetTestRoute: WidgetTestRoute,
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ function Component() {
|
|||||||
isToggling={toggleMutation.isPending}
|
isToggling={toggleMutation.isPending}
|
||||||
onToggle={(enabled) => handleToggle('counter', enabled)}
|
onToggle={(enabled) => handleToggle('counter', enabled)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BadgeWidgetSection
|
||||||
|
widget={counterWidget as any}
|
||||||
|
dashboardUrl={dashboardUrl}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -369,3 +374,96 @@ function CounterWidgetSection({
|
|||||||
</Widget>
|
</Widget>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BadgeWidgetSectionProps {
|
||||||
|
widget: {
|
||||||
|
id: string;
|
||||||
|
public: boolean;
|
||||||
|
} | null;
|
||||||
|
dashboardUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeWidgetSection({ widget, dashboardUrl }: BadgeWidgetSectionProps) {
|
||||||
|
const isEnabled = widget?.public ?? false;
|
||||||
|
const badgeUrl =
|
||||||
|
isEnabled && widget?.id
|
||||||
|
? `${dashboardUrl}/widget/badge?shareId=${widget.id}`
|
||||||
|
: null;
|
||||||
|
const badgeEmbedCode = badgeUrl
|
||||||
|
? `<a href="https://openpanel.dev" target="_blank" rel="noopener noreferrer" style="display: inline-block; overflow: hidden; border-radius: 8px;">
|
||||||
|
<iframe src="${badgeUrl}" height="48" width="250" style="border: none; overflow: hidden; pointer-events: none;" title="OpenPanel Analytics Badge"></iframe>
|
||||||
|
</a>`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!isEnabled || !badgeUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Widget className="max-w-screen-md w-full">
|
||||||
|
<WidgetHead className="row items-center justify-between gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<span className="title">Analytics Badge</span>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
A Product Hunt-style badge showing your 30-day unique visitor count.
|
||||||
|
Perfect for showcasing your analytics powered by OpenPanel.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</WidgetHead>
|
||||||
|
<WidgetBody className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Widget URL</h3>
|
||||||
|
<CopyInput label="" value={badgeUrl} className="w-full" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Direct link to the analytics badge widget.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Embed Code</h3>
|
||||||
|
<Syntax code={badgeEmbedCode!} language="bash" />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Copy this code and paste it into your website HTML where you want
|
||||||
|
the badge to appear.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">Preview</h3>
|
||||||
|
<div className="border rounded-lg p-4 bg-muted/30">
|
||||||
|
<a
|
||||||
|
href="https://openpanel.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: '8px',
|
||||||
|
display: 'inline-block',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
src={badgeUrl}
|
||||||
|
height="48"
|
||||||
|
width="250"
|
||||||
|
className="border-0 pointer-events-none"
|
||||||
|
title="Analytics Badge Preview"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
icon={ExternalLinkIcon}
|
||||||
|
onClick={() =>
|
||||||
|
window.open(badgeUrl, '_blank', 'noopener,noreferrer')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open in new tab
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
76
apps/start/src/routes/widget/badge.tsx
Normal file
76
apps/start/src/routes/widget/badge.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { LogoSquare } from '@/components/logo';
|
||||||
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { UsersIcon } from 'lucide-react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const widgetSearchSchema = z.object({
|
||||||
|
shareId: z.string(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/widget/badge')({
|
||||||
|
component: RouteComponent,
|
||||||
|
validateSearch: widgetSearchSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const { shareId, color } = Route.useSearch();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
// Fetch widget data
|
||||||
|
const { data, isLoading } = useQuery(
|
||||||
|
trpc.widget.badge.queryOptions({ shareId }),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <BadgeWidget visitors={0} isLoading color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return <BadgeWidget visitors={0} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BadgeWidget visitors={data.visitors} color={color} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BadgeWidgetProps {
|
||||||
|
visitors: number;
|
||||||
|
isLoading?: boolean;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BadgeWidget({ visitors, isLoading, color }: BadgeWidgetProps) {
|
||||||
|
const number = useNumber();
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 group inline-flex items-center gap-3 rounded-lg center-center px-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Logo on the left */}
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<LogoSquare className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center text */}
|
||||||
|
<div className="flex flex-col gap-0.5 flex-1 min-w-0 items-start -mt-px">
|
||||||
|
<div className="text-[10px] font-medium uppercase tracking-wide text-white/80">
|
||||||
|
ANALYTICS FROM
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold text-white leading-tight">OpenPanel</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Visitor count on the right */}
|
||||||
|
<div className="col center-center flex-shrink-0 gap-1">
|
||||||
|
<UsersIcon className="size-4 text-white" />
|
||||||
|
<div className="text-sm font-medium text-white tabular-nums">
|
||||||
|
{isLoading ? <span>...</span> : number.short(visitors)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
eventBuffer,
|
eventBuffer,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
|
import { getCache } from '@openpanel/redis';
|
||||||
import {
|
import {
|
||||||
zCounterWidgetOptions,
|
zCounterWidgetOptions,
|
||||||
zRealtimeWidgetOptions,
|
zRealtimeWidgetOptions,
|
||||||
@@ -144,6 +145,49 @@ export const widgetRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
badge: publicProcedure
|
||||||
|
.input(z.object({ shareId: z.string() }))
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const widget = await db.shareWidget.findUnique({
|
||||||
|
where: {
|
||||||
|
id: input.shareId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!widget || !widget.public) {
|
||||||
|
throw TRPCNotFoundError('Widget not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.options.type !== 'counter') {
|
||||||
|
throw TRPCNotFoundError('Invalid widget type');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { projectId } = widget;
|
||||||
|
const { timezone } = await getSettingsForProject(projectId);
|
||||||
|
|
||||||
|
// Cache for 5 minutes since this queries 30 days of data
|
||||||
|
const cacheKey = `widget:badge:${projectId}`;
|
||||||
|
const visitors = await getCache(
|
||||||
|
cacheKey,
|
||||||
|
5 * 60, // 5 minutes
|
||||||
|
async () => {
|
||||||
|
const uniqueVisitorsQuery = clix(ch, timezone)
|
||||||
|
.select<{ count: number }>(['uniq(profile_id) as count'])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 DAY'));
|
||||||
|
|
||||||
|
const result = await uniqueVisitorsQuery.execute();
|
||||||
|
return result[0]?.count || 0;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
visitors,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
realtimeData: publicProcedure
|
realtimeData: publicProcedure
|
||||||
.input(z.object({ shareId: z.string() }))
|
.input(z.object({ shareId: z.string() }))
|
||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user