feat: add product hunt badge widget

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-22 10:11:44 +01:00
parent deb3c3d20c
commit 67301d928c
4 changed files with 239 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as WidgetTestRouteImport } from './routes/widget/test'
import { Route as WidgetRealtimeRouteImport } from './routes/widget/realtime'
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 ApiConfigRouteImport } from './routes/api/config'
import { Route as PublicOnboardingRouteImport } from './routes/_public.onboarding'
@@ -138,6 +139,11 @@ const WidgetCounterRoute = WidgetCounterRouteImport.update({
path: '/widget/counter',
getParentRoute: () => rootRouteImport,
} as any)
const WidgetBadgeRoute = WidgetBadgeRouteImport.update({
id: '/widget/badge',
path: '/widget/badge',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthcheckRoute = ApiHealthcheckRouteImport.update({
id: '/api/healthcheck',
path: '/api/healthcheck',
@@ -531,6 +537,7 @@ export interface FileRoutesByFullPath {
'/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
@@ -596,6 +603,7 @@ export interface FileRoutesByTo {
'/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
@@ -659,6 +667,7 @@ export interface FileRoutesById {
'/_public/onboarding': typeof PublicOnboardingRoute
'/api/config': typeof ApiConfigRoute
'/api/healthcheck': typeof ApiHealthcheckRoute
'/widget/badge': typeof WidgetBadgeRoute
'/widget/counter': typeof WidgetCounterRoute
'/widget/realtime': typeof WidgetRealtimeRoute
'/widget/test': typeof WidgetTestRoute
@@ -734,6 +743,7 @@ export interface FileRouteTypes {
| '/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
@@ -799,6 +809,7 @@ export interface FileRouteTypes {
| '/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
@@ -861,6 +872,7 @@ export interface FileRouteTypes {
| '/_public/onboarding'
| '/api/config'
| '/api/healthcheck'
| '/widget/badge'
| '/widget/counter'
| '/widget/realtime'
| '/widget/test'
@@ -935,6 +947,7 @@ export interface RootRouteChildren {
StepsRoute: typeof StepsRouteWithChildren
ApiConfigRoute: typeof ApiConfigRoute
ApiHealthcheckRoute: typeof ApiHealthcheckRoute
WidgetBadgeRoute: typeof WidgetBadgeRoute
WidgetCounterRoute: typeof WidgetCounterRoute
WidgetRealtimeRoute: typeof WidgetRealtimeRoute
WidgetTestRoute: typeof WidgetTestRoute
@@ -1001,6 +1014,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WidgetCounterRouteImport
parentRoute: typeof rootRouteImport
}
'/widget/badge': {
id: '/widget/badge'
path: '/widget/badge'
fullPath: '/widget/badge'
preLoaderRoute: typeof WidgetBadgeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/healthcheck': {
id: '/api/healthcheck'
path: '/api/healthcheck'
@@ -1874,6 +1894,7 @@ const rootRouteChildren: RootRouteChildren = {
StepsRoute: StepsRouteWithChildren,
ApiConfigRoute: ApiConfigRoute,
ApiHealthcheckRoute: ApiHealthcheckRoute,
WidgetBadgeRoute: WidgetBadgeRoute,
WidgetCounterRoute: WidgetCounterRoute,
WidgetRealtimeRoute: WidgetRealtimeRoute,
WidgetTestRoute: WidgetTestRoute,

View File

@@ -107,6 +107,11 @@ function Component() {
isToggling={toggleMutation.isPending}
onToggle={(enabled) => handleToggle('counter', enabled)}
/>
<BadgeWidgetSection
widget={counterWidget as any}
dashboardUrl={dashboardUrl}
/>
</div>
);
}
@@ -369,3 +374,96 @@ function CounterWidgetSection({
</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>
);
}

View 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>
);
}

View File

@@ -9,6 +9,7 @@ import {
eventBuffer,
getSettingsForProject,
} from '@openpanel/db';
import { getCache } from '@openpanel/redis';
import {
zCounterWidgetOptions,
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
.input(z.object({ shareId: z.string() }))
.query(async ({ input }) => {