367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
import CopyInput from '@/components/forms/copy-input';
|
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
|
import Syntax from '@/components/syntax';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
|
import { useAppContext } from '@/hooks/use-app-context';
|
|
import { useAppParams } from '@/hooks/use-app-params';
|
|
import { useTRPC } from '@/integrations/trpc/react';
|
|
import type {
|
|
IRealtimeWidgetOptions,
|
|
IWidgetType,
|
|
} from '@openpanel/validation';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { createFileRoute } from '@tanstack/react-router';
|
|
import { ExternalLinkIcon } from 'lucide-react';
|
|
import { useEffect, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
|
|
export const Route = createFileRoute(
|
|
'/_app/$organizationId/$projectId/settings/_tabs/widgets',
|
|
)({
|
|
component: Component,
|
|
});
|
|
|
|
function Component() {
|
|
const { projectId, organizationId } = useAppParams();
|
|
const { dashboardUrl } = useAppContext();
|
|
const trpc = useTRPC();
|
|
const queryClient = useQueryClient();
|
|
|
|
// Fetch both widget types
|
|
const realtimeWidgetQuery = useQuery(
|
|
trpc.widget.get.queryOptions({ projectId, type: 'realtime' }),
|
|
);
|
|
const counterWidgetQuery = useQuery(
|
|
trpc.widget.get.queryOptions({ projectId, type: 'counter' }),
|
|
);
|
|
|
|
// Toggle mutation
|
|
const toggleMutation = useMutation(
|
|
trpc.widget.toggle.mutationOptions({
|
|
onSuccess: (_, variables) => {
|
|
queryClient.invalidateQueries(
|
|
trpc.widget.get.queryFilter({ projectId, type: variables.type }),
|
|
);
|
|
toast.success(variables.enabled ? 'Widget enabled' : 'Widget disabled');
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error.message || 'Failed to update widget');
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Update options mutation
|
|
const updateOptionsMutation = useMutation(
|
|
trpc.widget.updateOptions.mutationOptions({
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries(
|
|
trpc.widget.get.queryFilter({ projectId, type: 'realtime' }),
|
|
);
|
|
toast.success('Widget options updated');
|
|
},
|
|
onError: (error) => {
|
|
toast.error(error.message || 'Failed to update options');
|
|
},
|
|
}),
|
|
);
|
|
|
|
const handleToggle = (type: IWidgetType, enabled: boolean) => {
|
|
toggleMutation.mutate({
|
|
projectId,
|
|
organizationId,
|
|
type,
|
|
enabled,
|
|
});
|
|
};
|
|
|
|
if (realtimeWidgetQuery.isLoading || counterWidgetQuery.isLoading) {
|
|
return <FullPageLoadingState />;
|
|
}
|
|
|
|
const realtimeWidget = realtimeWidgetQuery.data;
|
|
const counterWidget = counterWidgetQuery.data;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<RealtimeWidgetSection
|
|
widget={realtimeWidget as any}
|
|
dashboardUrl={dashboardUrl}
|
|
isToggling={toggleMutation.isPending}
|
|
isUpdatingOptions={updateOptionsMutation.isPending}
|
|
onToggle={(enabled) => handleToggle('realtime', enabled)}
|
|
onUpdateOptions={(options) =>
|
|
updateOptionsMutation.mutate({
|
|
projectId,
|
|
organizationId,
|
|
options,
|
|
})
|
|
}
|
|
/>
|
|
|
|
<CounterWidgetSection
|
|
widget={counterWidget as any}
|
|
dashboardUrl={dashboardUrl}
|
|
isToggling={toggleMutation.isPending}
|
|
onToggle={(enabled) => handleToggle('counter', enabled)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface RealtimeWidgetSectionProps {
|
|
widget: {
|
|
id: string;
|
|
public: boolean;
|
|
options: IRealtimeWidgetOptions;
|
|
} | null;
|
|
dashboardUrl: string;
|
|
isToggling: boolean;
|
|
isUpdatingOptions: boolean;
|
|
onToggle: (enabled: boolean) => void;
|
|
onUpdateOptions: (options: IRealtimeWidgetOptions) => void;
|
|
}
|
|
|
|
function RealtimeWidgetSection({
|
|
widget,
|
|
dashboardUrl,
|
|
isToggling,
|
|
isUpdatingOptions,
|
|
onToggle,
|
|
onUpdateOptions,
|
|
}: RealtimeWidgetSectionProps) {
|
|
const isEnabled = widget?.public ?? false;
|
|
const widgetUrl =
|
|
isEnabled && widget?.id
|
|
? `${dashboardUrl}/widget/realtime?shareId=${widget.id}`
|
|
: null;
|
|
const embedCode = widgetUrl
|
|
? `<iframe src="${widgetUrl}" width="100%" height="400" frameborder="0" style="border-radius: 8px;"></iframe>`
|
|
: null;
|
|
|
|
// Default options
|
|
const defaultOptions: IRealtimeWidgetOptions = {
|
|
type: 'realtime',
|
|
referrers: true,
|
|
countries: true,
|
|
paths: false,
|
|
};
|
|
|
|
const [options, setOptions] = useState<IRealtimeWidgetOptions>(
|
|
(widget?.options as IRealtimeWidgetOptions) || defaultOptions,
|
|
);
|
|
|
|
// Update local options when widget data changes
|
|
useEffect(() => {
|
|
if (widget?.options) {
|
|
setOptions(widget.options as IRealtimeWidgetOptions);
|
|
}
|
|
}, [widget?.options]);
|
|
|
|
const handleUpdateOptions = (newOptions: IRealtimeWidgetOptions) => {
|
|
setOptions(newOptions);
|
|
onUpdateOptions(newOptions);
|
|
};
|
|
|
|
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">Realtime Widget</span>
|
|
<p className="text-muted-foreground">
|
|
Embed a realtime visitor counter widget on your website. The widget
|
|
shows live visitor count, activity histogram, top countries,
|
|
referrers and paths.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={isEnabled}
|
|
onCheckedChange={onToggle}
|
|
disabled={isToggling}
|
|
/>
|
|
</WidgetHead>
|
|
{isEnabled && (
|
|
<WidgetBody className="space-y-6">
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-medium">Widget Options</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="referrers" className="text-sm">
|
|
Show Referrers
|
|
</Label>
|
|
<Switch
|
|
id="referrers"
|
|
checked={options.referrers}
|
|
onCheckedChange={(checked) =>
|
|
handleUpdateOptions({ ...options, referrers: checked })
|
|
}
|
|
disabled={isUpdatingOptions}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="countries" className="text-sm">
|
|
Show Countries
|
|
</Label>
|
|
<Switch
|
|
id="countries"
|
|
checked={options.countries}
|
|
onCheckedChange={(checked) =>
|
|
handleUpdateOptions({ ...options, countries: checked })
|
|
}
|
|
disabled={isUpdatingOptions}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="paths" className="text-sm">
|
|
Show Paths
|
|
</Label>
|
|
<Switch
|
|
id="paths"
|
|
checked={options.paths}
|
|
onCheckedChange={(checked) =>
|
|
handleUpdateOptions({ ...options, paths: checked })
|
|
}
|
|
disabled={isUpdatingOptions}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-medium">Widget URL</h3>
|
|
<CopyInput label="" value={widgetUrl!} className="w-full" />
|
|
<p className="text-xs text-muted-foreground">
|
|
Direct link to the widget. You can open this in a new tab or embed
|
|
it.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-medium">Embed Code</h3>
|
|
<Syntax code={embedCode!} language="bash" />
|
|
<p className="text-xs text-muted-foreground">
|
|
Copy this code and paste it into your website HTML where you want
|
|
the widget to appear.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-medium">Preview</h3>
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<iframe
|
|
src={widgetUrl!}
|
|
width="100%"
|
|
height="600"
|
|
className="border-0"
|
|
title="Realtime Widget Preview"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
icon={ExternalLinkIcon}
|
|
onClick={() =>
|
|
window.open(widgetUrl!, '_blank', 'noopener,noreferrer')
|
|
}
|
|
>
|
|
Open in new tab
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</WidgetBody>
|
|
)}
|
|
</Widget>
|
|
);
|
|
}
|
|
|
|
interface CounterWidgetSectionProps {
|
|
widget: {
|
|
id: string;
|
|
public: boolean;
|
|
} | null;
|
|
dashboardUrl: string;
|
|
isToggling: boolean;
|
|
onToggle: (enabled: boolean) => void;
|
|
}
|
|
|
|
function CounterWidgetSection({
|
|
widget,
|
|
dashboardUrl,
|
|
isToggling,
|
|
onToggle,
|
|
}: CounterWidgetSectionProps) {
|
|
const isEnabled = widget?.public ?? false;
|
|
const counterUrl =
|
|
isEnabled && widget?.id
|
|
? `${dashboardUrl}/widget/counter?shareId=${widget.id}`
|
|
: null;
|
|
const counterEmbedCode = counterUrl
|
|
? `<iframe src="${counterUrl}" height="32" style="border: none; overflow: hidden;" title="Visitor Counter"></iframe>`
|
|
: 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">Counter Widget</span>
|
|
<p className="text-muted-foreground">
|
|
A compact live visitor counter badge you can embed anywhere. Shows
|
|
the current number of unique visitors with a live indicator.
|
|
</p>
|
|
</div>
|
|
<Switch
|
|
checked={isEnabled}
|
|
onCheckedChange={onToggle}
|
|
disabled={isToggling}
|
|
/>
|
|
</WidgetHead>
|
|
{isEnabled && counterUrl && (
|
|
<WidgetBody className="space-y-6">
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-medium">Widget URL</h3>
|
|
<CopyInput label="" value={counterUrl} className="w-full" />
|
|
<p className="text-xs text-muted-foreground">
|
|
Direct link to the counter widget.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-sm font-medium">Embed Code</h3>
|
|
<Syntax code={counterEmbedCode!} language="bash" />
|
|
<p className="text-xs text-muted-foreground">
|
|
Copy this code and paste it into your website HTML where you want
|
|
the counter 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">
|
|
<iframe
|
|
src={counterUrl}
|
|
height="32"
|
|
className="border-0"
|
|
title="Counter Widget Preview"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
icon={ExternalLinkIcon}
|
|
onClick={() =>
|
|
window.open(counterUrl, '_blank', 'noopener,noreferrer')
|
|
}
|
|
>
|
|
Open in new tab
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</WidgetBody>
|
|
)}
|
|
</Widget>
|
|
);
|
|
}
|