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 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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user