Compare commits
5 Commits
feature/te
...
feature/on
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12e8c9beaa | ||
|
|
f9b1ec5038 | ||
|
|
3fa1a5429e | ||
|
|
a58761e8d7 | ||
|
|
56f1c5e894 |
@@ -41,7 +41,6 @@ COPY packages/payments/package.json packages/payments/
|
|||||||
COPY packages/constants/package.json packages/constants/
|
COPY packages/constants/package.json packages/constants/
|
||||||
COPY packages/validation/package.json packages/validation/
|
COPY packages/validation/package.json packages/validation/
|
||||||
COPY packages/integrations/package.json packages/integrations/
|
COPY packages/integrations/package.json packages/integrations/
|
||||||
COPY packages/js-runtime/package.json packages/js-runtime/
|
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
|
|
||||||
# BUILD
|
# BUILD
|
||||||
@@ -109,7 +108,6 @@ COPY --from=build /app/packages/payments ./packages/payments
|
|||||||
COPY --from=build /app/packages/constants ./packages/constants
|
COPY --from=build /app/packages/constants ./packages/constants
|
||||||
COPY --from=build /app/packages/validation ./packages/validation
|
COPY --from=build /app/packages/validation ./packages/validation
|
||||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||||
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
|
|
||||||
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||||
RUN pnpm db:codegen
|
RUN pnpm db:codegen
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
|||||||
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
||||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||||
import { getRedisCache, getRedisQueue } from '@openpanel/redis';
|
import { getRedisCache } from '@openpanel/redis';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type IDecrementPayload,
|
type IDecrementPayload,
|
||||||
@@ -419,7 +419,7 @@ export async function fetchDeviceId(
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const multi = getRedisQueue().multi();
|
const multi = getRedisCache().multi();
|
||||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
||||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
||||||
const res = await multi.exec();
|
const res = await multi.exec();
|
||||||
|
|||||||
@@ -189,48 +189,28 @@ export default function OpenSourcePage() {
|
|||||||
description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word."
|
description="Showcase your visitor count with our real-time analytics widget. It's completely optional but helps spread the word."
|
||||||
icon={GlobeIcon}
|
icon={GlobeIcon}
|
||||||
>
|
>
|
||||||
<a
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
href="https://openpanel.dev"
|
Display real-time visitor counts, page views, or other
|
||||||
style={{
|
metrics on your project's website.
|
||||||
display: 'inline-block',
|
</p>
|
||||||
overflow: 'hidden',
|
|
||||||
borderRadius: '8px',
|
|
||||||
width: '250px',
|
|
||||||
height: '48px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%231F1F1F"
|
|
||||||
height="48"
|
|
||||||
width="100%"
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
overflow: 'hidden',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
title="OpenPanel Analytics Badge"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</FeatureCard>
|
</FeatureCard>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
That's it. No complicated requirements, no hidden fees, no
|
That's it. No complicated requirements, no hidden fees, no
|
||||||
catch. We just want to help open source projects succeed.
|
catch. We just want to help open source projects succeed.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="text-center text-xs text-muted-foreground">
|
||||||
<div className="text-center text-xs text-muted-foreground">
|
<iframe
|
||||||
<iframe
|
title="Realtime Widget"
|
||||||
title="Realtime Widget"
|
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
|
||||||
src="https://dashboard.openpanel.dev/widget/realtime?shareId=26wVGY"
|
width="300"
|
||||||
width="300"
|
height="400"
|
||||||
height="400"
|
className="rounded-xl border mb-2"
|
||||||
className="rounded-xl border mb-2"
|
/>
|
||||||
/>
|
Analytics from{' '}
|
||||||
Analytics from{' '}
|
<a className="underline" href="https://openpanel.dev">
|
||||||
<a className="underline" href="https://openpanel.dev">
|
OpenPanel.dev
|
||||||
OpenPanel.dev
|
</a>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export function FeatureCard({
|
|||||||
<h3 className="text-lg font-semibold">{title}</h3>
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
|
||||||
</FeatureCardContainer>
|
</FeatureCardContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,28 +79,10 @@ export async function Footer() {
|
|||||||
<div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32">
|
<div className="col text-muted-foreground border-t pt-8 mt-16 gap-8 relative bg-background/70 pb-32">
|
||||||
<div className="container col md:row justify-between gap-8">
|
<div className="container col md:row justify-between gap-8">
|
||||||
<div>
|
<div>
|
||||||
<a
|
<Link href="/" className="row items-center font-medium -ml-3">
|
||||||
href="https://openpanel.dev"
|
<Logo className="h-6" />
|
||||||
style={{
|
{baseOptions().nav?.title}
|
||||||
display: 'inline-block',
|
</Link>
|
||||||
overflow: 'hidden',
|
|
||||||
borderRadius: '8px',
|
|
||||||
width: '100%',
|
|
||||||
height: '48px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<iframe
|
|
||||||
src="https://dashboard.openpanel.dev/widget/badge?shareId=ancygl&color=%230B0B0B"
|
|
||||||
height="48"
|
|
||||||
width="100%"
|
|
||||||
style={{
|
|
||||||
border: 'none',
|
|
||||||
overflow: 'hidden',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
title="OpenPanel Analytics Badge"
|
|
||||||
/>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
<Social />
|
<Social />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,16 +84,9 @@
|
|||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"ai": "^4.2.10",
|
"ai": "^4.2.10",
|
||||||
"bind-event-listener": "^3.0.0",
|
"bind-event-listener": "^3.0.0",
|
||||||
"@codemirror/commands": "^6.7.0",
|
|
||||||
"@codemirror/lang-javascript": "^6.2.0",
|
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
|
||||||
"@codemirror/state": "^6.4.0",
|
|
||||||
"@codemirror/theme-one-dark": "^6.1.3",
|
|
||||||
"@codemirror/view": "^6.35.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^0.2.1",
|
"cmdk": "^0.2.1",
|
||||||
"codemirror": "^6.0.1",
|
|
||||||
"d3": "^7.8.5",
|
"d3": "^7.8.5",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
"debounce": "^2.2.0",
|
"debounce": "^2.2.0",
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function useColumns() {
|
|||||||
if (profile) {
|
if (profile) {
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
href={`/profiles/${profile.id}`}
|
||||||
className="whitespace-nowrap font-medium hover:underline"
|
className="whitespace-nowrap font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{getProfileName(profile)}
|
{getProfileName(profile)}
|
||||||
@@ -117,7 +117,7 @@ export function useColumns() {
|
|||||||
if (profileId && profileId !== deviceId) {
|
if (profileId && profileId !== deviceId) {
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(profileId)}`}
|
href={`/profiles/${profileId}`}
|
||||||
className="whitespace-nowrap font-medium hover:underline"
|
className="whitespace-nowrap font-medium hover:underline"
|
||||||
>
|
>
|
||||||
Unknown
|
Unknown
|
||||||
@@ -128,7 +128,7 @@ export function useColumns() {
|
|||||||
if (deviceId) {
|
if (deviceId) {
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(deviceId)}`}
|
href={`/profiles/${deviceId}`}
|
||||||
className="whitespace-nowrap font-medium hover:underline"
|
className="whitespace-nowrap font-medium hover:underline"
|
||||||
>
|
>
|
||||||
Anonymous
|
Anonymous
|
||||||
|
|||||||
@@ -1,53 +1,18 @@
|
|||||||
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
|
import { InputWithLabel } from '@/components/forms/input-with-label';
|
||||||
import { JsonEditor } from '@/components/json-editor';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { zCreateWebhookIntegration } from '@openpanel/validation';
|
import { zCreateWebhookIntegration } from '@openpanel/validation';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { PlusIcon, TrashIcon } from 'lucide-react';
|
|
||||||
import { path, mergeDeepRight } from 'ramda';
|
import { path, mergeDeepRight } from 'ramda';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { Controller, useFieldArray, useWatch } from 'react-hook-form';
|
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
type IForm = z.infer<typeof zCreateWebhookIntegration>;
|
type IForm = z.infer<typeof zCreateWebhookIntegration>;
|
||||||
|
|
||||||
const DEFAULT_TRANSFORMER = `(payload) => {
|
|
||||||
return payload;
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// Convert Record<string, string> to array format for form
|
|
||||||
function headersToArray(
|
|
||||||
headers: Record<string, string> | undefined,
|
|
||||||
): { key: string; value: string }[] {
|
|
||||||
if (!headers || Object.keys(headers).length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return Object.entries(headers).map(([key, value]) => ({ key, value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert array format back to Record<string, string> for API
|
|
||||||
function headersToRecord(
|
|
||||||
headers: { key: string; value: string }[],
|
|
||||||
): Record<string, string> {
|
|
||||||
return headers.reduce(
|
|
||||||
(acc, { key, value }) => {
|
|
||||||
if (key.trim()) {
|
|
||||||
acc[key.trim()] = value;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, string>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WebhookIntegrationForm({
|
export function WebhookIntegrationForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
@@ -56,13 +21,6 @@ export function WebhookIntegrationForm({
|
|||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { organizationId } = useAppParams();
|
const { organizationId } = useAppParams();
|
||||||
|
|
||||||
// Convert headers from Record to array format for form UI
|
|
||||||
const defaultHeaders =
|
|
||||||
defaultValues?.config && 'headers' in defaultValues.config
|
|
||||||
? headersToArray(defaultValues.config.headers)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const form = useForm<IForm>({
|
const form = useForm<IForm>({
|
||||||
defaultValues: mergeDeepRight(
|
defaultValues: mergeDeepRight(
|
||||||
{
|
{
|
||||||
@@ -72,68 +30,18 @@ export function WebhookIntegrationForm({
|
|||||||
type: 'webhook' as const,
|
type: 'webhook' as const,
|
||||||
url: '',
|
url: '',
|
||||||
headers: {},
|
headers: {},
|
||||||
mode: 'message' as const,
|
|
||||||
javascriptTemplate: undefined,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultValues ?? {},
|
defaultValues ?? {},
|
||||||
),
|
),
|
||||||
resolver: zodResolver(zCreateWebhookIntegration),
|
resolver: zodResolver(zCreateWebhookIntegration),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use a separate form for headers array to work with useFieldArray
|
|
||||||
const headersForm = useForm<{ headers: { key: string; value: string }[] }>({
|
|
||||||
defaultValues: {
|
|
||||||
headers: defaultHeaders,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const headersArray = useFieldArray({
|
|
||||||
control: headersForm.control,
|
|
||||||
name: 'headers',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Watch headers array and sync to main form
|
|
||||||
const watchedHeaders = useWatch({
|
|
||||||
control: headersForm.control,
|
|
||||||
name: 'headers',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync headers array changes back to main form
|
|
||||||
useEffect(() => {
|
|
||||||
if (watchedHeaders) {
|
|
||||||
const validHeaders = watchedHeaders.filter(
|
|
||||||
(h): h is { key: string; value: string } =>
|
|
||||||
h !== undefined &&
|
|
||||||
typeof h.key === 'string' &&
|
|
||||||
typeof h.value === 'string',
|
|
||||||
);
|
|
||||||
form.setValue('config.headers', headersToRecord(validHeaders), {
|
|
||||||
shouldValidate: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [watchedHeaders, form]);
|
|
||||||
|
|
||||||
const mode = form.watch('config.mode');
|
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
trpc.integration.createOrUpdate.mutationOptions({
|
trpc.integration.createOrUpdate.mutationOptions({
|
||||||
onSuccess,
|
onSuccess,
|
||||||
onError(error) {
|
onError() {
|
||||||
// Handle validation errors from tRPC
|
toast.error('Failed to create integration');
|
||||||
if (error.data?.code === 'BAD_REQUEST') {
|
|
||||||
const errorMessage = error.message || 'Invalid JavaScript template';
|
|
||||||
toast.error(errorMessage);
|
|
||||||
// Set form error if it's a JavaScript template error
|
|
||||||
if (errorMessage.includes('JavaScript template')) {
|
|
||||||
form.setError('config.javascriptTemplate', {
|
|
||||||
type: 'manual',
|
|
||||||
message: errorMessage,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.error('Failed to create integration');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -162,176 +70,7 @@ export function WebhookIntegrationForm({
|
|||||||
{...form.register('config.url')}
|
{...form.register('config.url')}
|
||||||
error={path(['config', 'url', 'message'], form.formState.errors)}
|
error={path(['config', 'url', 'message'], form.formState.errors)}
|
||||||
/>
|
/>
|
||||||
|
<Button type="submit">Create</Button>
|
||||||
<WithLabel
|
|
||||||
label="Headers"
|
|
||||||
info="Add custom HTTP headers to include with webhook requests"
|
|
||||||
>
|
|
||||||
<div className="col gap-2">
|
|
||||||
{headersArray.fields.map((field, index) => (
|
|
||||||
<div key={field.id} className="row gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Header Name"
|
|
||||||
{...headersForm.register(`headers.${index}.key`)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder="Header Value"
|
|
||||||
{...headersForm.register(`headers.${index}.value`)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => headersArray.remove(index)}
|
|
||||||
className="text-destructive"
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => headersArray.append({ key: '', value: '' })}
|
|
||||||
className="self-start"
|
|
||||||
icon={PlusIcon}
|
|
||||||
>
|
|
||||||
Add Header
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</WithLabel>
|
|
||||||
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="config.mode"
|
|
||||||
render={({ field }) => (
|
|
||||||
<WithLabel
|
|
||||||
label="Payload Format"
|
|
||||||
info="Choose how to format the webhook payload"
|
|
||||||
>
|
|
||||||
<Combobox
|
|
||||||
{...field}
|
|
||||||
className="w-full"
|
|
||||||
placeholder="Select format"
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: 'Message',
|
|
||||||
value: 'message' as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'JavaScript',
|
|
||||||
value: 'javascript' as const,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={field.value ?? 'message'}
|
|
||||||
onChange={field.onChange}
|
|
||||||
/>
|
|
||||||
</WithLabel>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{mode === 'javascript' && (
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="config.javascriptTemplate"
|
|
||||||
render={({ field }) => (
|
|
||||||
<WithLabel
|
|
||||||
label="JavaScript Transform"
|
|
||||||
info={
|
|
||||||
<div className="prose dark:prose-invert max-w-none">
|
|
||||||
<p>
|
|
||||||
Write a JavaScript function that transforms the event
|
|
||||||
payload. The function receives <code>payload</code> as a
|
|
||||||
parameter and should return an object.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-semibold mt-2">
|
|
||||||
Available in payload:
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm">
|
|
||||||
<li>
|
|
||||||
<code>payload.name</code> - Event name
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>payload.profileId</code> - User profile ID
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>payload.properties</code> - Full properties object
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>payload.properties.your.property</code> - Nested
|
|
||||||
property value
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>payload.profile.firstName</code> - Profile property
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div className="flex gap-x-2 flex-wrap mt-1">
|
|
||||||
<code>country</code>
|
|
||||||
<code>city</code>
|
|
||||||
<code>device</code>
|
|
||||||
<code>os</code>
|
|
||||||
<code>browser</code>
|
|
||||||
<code>path</code>
|
|
||||||
<code>createdAt</code>
|
|
||||||
and more...
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-sm font-semibold mt-2">
|
|
||||||
Available helpers:
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm">
|
|
||||||
<li>
|
|
||||||
<code>Math</code>, <code>Date</code>, <code>JSON</code>,{' '}
|
|
||||||
<code>Array</code>, <code>String</code>,{' '}
|
|
||||||
<code>Object</code>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p className="text-sm mt-2">
|
|
||||||
<strong>Example:</strong>
|
|
||||||
</p>
|
|
||||||
<pre className="text-xs bg-muted p-2 rounded mt-1 overflow-x-auto">
|
|
||||||
{`(payload) => ({
|
|
||||||
event: payload.name,
|
|
||||||
user: payload.profileId,
|
|
||||||
data: payload.properties,
|
|
||||||
timestamp: new Date(payload.createdAt).toISOString(),
|
|
||||||
location: \`\${payload.city}, \${payload.country}\`
|
|
||||||
})`}
|
|
||||||
</pre>
|
|
||||||
<p className="text-sm mt-2 text-yellow-600 dark:text-yellow-400">
|
|
||||||
<strong>Security:</strong> Network calls, file system
|
|
||||||
access, and other dangerous operations are blocked.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<JsonEditor
|
|
||||||
value={field.value ?? DEFAULT_TRANSFORMER}
|
|
||||||
onChange={(value) => {
|
|
||||||
field.onChange(value);
|
|
||||||
// Clear error when user starts typing
|
|
||||||
if (form.formState.errors.config?.javascriptTemplate) {
|
|
||||||
form.clearErrors('config.javascriptTemplate');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={DEFAULT_TRANSFORMER}
|
|
||||||
minHeight="300px"
|
|
||||||
language="javascript"
|
|
||||||
/>
|
|
||||||
{form.formState.errors.config?.javascriptTemplate && (
|
|
||||||
<p className="mt-1 text-sm text-destructive">
|
|
||||||
{form.formState.errors.config.javascriptTemplate.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</WithLabel>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button type="submit">{defaultValues?.id ? 'Update' : 'Create'}</Button>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { basicSetup } from 'codemirror';
|
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
|
||||||
import { json } from '@codemirror/lang-json';
|
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
|
||||||
import {
|
|
||||||
Compartment,
|
|
||||||
EditorState,
|
|
||||||
type Extension,
|
|
||||||
} from '@codemirror/state';
|
|
||||||
import { EditorView, keymap } from '@codemirror/view';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useTheme } from './theme-provider';
|
|
||||||
|
|
||||||
interface JsonEditorProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
className?: string;
|
|
||||||
minHeight?: string;
|
|
||||||
language?: 'json' | 'javascript';
|
|
||||||
onValidate?: (isValid: boolean, error?: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function JsonEditor({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder = '{}',
|
|
||||||
className = '',
|
|
||||||
minHeight = '200px',
|
|
||||||
language = 'json',
|
|
||||||
onValidate,
|
|
||||||
}: JsonEditorProps) {
|
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
|
||||||
const themeCompartmentRef = useRef<Compartment | null>(null);
|
|
||||||
const languageCompartmentRef = useRef<Compartment | null>(null);
|
|
||||||
const { appTheme } = useTheme();
|
|
||||||
const [isValid, setIsValid] = useState(true);
|
|
||||||
const [error, setError] = useState<string | undefined>();
|
|
||||||
const isUpdatingRef = useRef(false);
|
|
||||||
|
|
||||||
const validateContent = (content: string) => {
|
|
||||||
if (!content.trim()) {
|
|
||||||
setIsValid(true);
|
|
||||||
setError(undefined);
|
|
||||||
onValidate?.(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (language === 'json') {
|
|
||||||
try {
|
|
||||||
JSON.parse(content);
|
|
||||||
setIsValid(true);
|
|
||||||
setError(undefined);
|
|
||||||
onValidate?.(true);
|
|
||||||
} catch (e) {
|
|
||||||
setIsValid(false);
|
|
||||||
const errorMsg =
|
|
||||||
e instanceof Error ? e.message : 'Invalid JSON syntax';
|
|
||||||
setError(errorMsg);
|
|
||||||
onValidate?.(false, errorMsg);
|
|
||||||
}
|
|
||||||
} else if (language === 'javascript') {
|
|
||||||
// No frontend validation for JavaScript - validation happens in tRPC
|
|
||||||
setIsValid(true);
|
|
||||||
setError(undefined);
|
|
||||||
onValidate?.(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create editor once on mount
|
|
||||||
useEffect(() => {
|
|
||||||
if (!editorRef.current || viewRef.current) return;
|
|
||||||
|
|
||||||
const themeCompartment = new Compartment();
|
|
||||||
themeCompartmentRef.current = themeCompartment;
|
|
||||||
|
|
||||||
const languageCompartment = new Compartment();
|
|
||||||
languageCompartmentRef.current = languageCompartment;
|
|
||||||
|
|
||||||
const extensions: Extension[] = [
|
|
||||||
basicSetup,
|
|
||||||
languageCompartment.of(language === 'javascript' ? [javascript()] : [json()]),
|
|
||||||
EditorState.tabSize.of(2),
|
|
||||||
EditorView.updateListener.of((update) => {
|
|
||||||
if (update.docChanged) {
|
|
||||||
isUpdatingRef.current = true;
|
|
||||||
const newValue = update.state.doc.toString();
|
|
||||||
onChange(newValue);
|
|
||||||
validateContent(newValue);
|
|
||||||
|
|
||||||
// Reset flag after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingRef.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
EditorView.theme({
|
|
||||||
'&': {
|
|
||||||
fontSize: '14px',
|
|
||||||
minHeight,
|
|
||||||
maxHeight: '400px',
|
|
||||||
},
|
|
||||||
'&.cm-editor': {
|
|
||||||
borderRadius: '6px',
|
|
||||||
border: `1px solid ${
|
|
||||||
isValid ? 'hsl(var(--border))' : 'hsl(var(--destructive))'
|
|
||||||
}`,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
'.cm-scroller': {
|
|
||||||
minHeight,
|
|
||||||
maxHeight: '400px',
|
|
||||||
overflow: 'auto',
|
|
||||||
},
|
|
||||||
'.cm-content': {
|
|
||||||
padding: '12px 12px 12px 0',
|
|
||||||
fontFamily:
|
|
||||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
|
|
||||||
minHeight,
|
|
||||||
},
|
|
||||||
'.cm-focused': {
|
|
||||||
outline: 'none',
|
|
||||||
},
|
|
||||||
'.cm-gutters': {
|
|
||||||
backgroundColor: 'hsl(var(--muted))',
|
|
||||||
borderRight: '1px solid hsl(var(--border))',
|
|
||||||
paddingLeft: '8px',
|
|
||||||
},
|
|
||||||
'.cm-lineNumbers .cm-gutterElement': {
|
|
||||||
color: 'hsl(var(--muted-foreground))',
|
|
||||||
paddingRight: '12px',
|
|
||||||
paddingLeft: '4px',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
themeCompartment.of(appTheme === 'dark' ? [oneDark] : []),
|
|
||||||
];
|
|
||||||
|
|
||||||
const state = EditorState.create({
|
|
||||||
doc: value,
|
|
||||||
extensions,
|
|
||||||
});
|
|
||||||
|
|
||||||
const view = new EditorView({
|
|
||||||
state,
|
|
||||||
parent: editorRef.current,
|
|
||||||
});
|
|
||||||
|
|
||||||
viewRef.current = view;
|
|
||||||
|
|
||||||
// Initial validation
|
|
||||||
validateContent(value);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
view.destroy();
|
|
||||||
viewRef.current = null;
|
|
||||||
themeCompartmentRef.current = null;
|
|
||||||
};
|
|
||||||
}, []); // Only create once
|
|
||||||
|
|
||||||
// Update theme using compartment
|
|
||||||
useEffect(() => {
|
|
||||||
if (!viewRef.current || !themeCompartmentRef.current) return;
|
|
||||||
|
|
||||||
viewRef.current.dispatch({
|
|
||||||
effects: themeCompartmentRef.current.reconfigure(
|
|
||||||
appTheme === 'dark' ? [oneDark] : [],
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}, [appTheme]);
|
|
||||||
|
|
||||||
// Update language using compartment
|
|
||||||
useEffect(() => {
|
|
||||||
if (!viewRef.current || !languageCompartmentRef.current) return;
|
|
||||||
|
|
||||||
viewRef.current.dispatch({
|
|
||||||
effects: languageCompartmentRef.current.reconfigure(
|
|
||||||
language === 'javascript' ? [javascript()] : [json()],
|
|
||||||
),
|
|
||||||
});
|
|
||||||
validateContent(value);
|
|
||||||
}, [language, value]);
|
|
||||||
|
|
||||||
// Update editor content when value changes externally
|
|
||||||
useEffect(() => {
|
|
||||||
if (!viewRef.current || isUpdatingRef.current) return;
|
|
||||||
|
|
||||||
const currentContent = viewRef.current.state.doc.toString();
|
|
||||||
if (currentContent !== value) {
|
|
||||||
viewRef.current.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: 0,
|
|
||||||
to: viewRef.current.state.doc.length,
|
|
||||||
insert: value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate after external update
|
|
||||||
validateContent(value);
|
|
||||||
}
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<div
|
|
||||||
ref={editorRef}
|
|
||||||
className={`rounded-md ${!isValid ? 'ring-1 ring-destructive' : ''}`}
|
|
||||||
/>
|
|
||||||
{!isValid && (
|
|
||||||
<p className="mt-1 text-sm text-destructive">
|
|
||||||
{error || `Invalid ${language === 'javascript' ? 'JavaScript' : 'JSON'}. Please check your syntax.`}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -149,7 +149,7 @@ export function useColumns() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(event.profileId)}`}
|
href={`/profiles/${event.profileId}`}
|
||||||
className="inline-flex min-w-full flex-none items-center gap-2"
|
className="inline-flex min-w-full flex-none items-center gap-2"
|
||||||
>
|
>
|
||||||
{event.profileId}
|
{event.profileId}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { cn } from '@/utils/cn';
|
|||||||
import { DialogTitle } from '@radix-ui/react-dialog';
|
import { DialogTitle } from '@radix-ui/react-dialog';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { SearchIcon } from 'lucide-react';
|
import { SearchIcon } from 'lucide-react';
|
||||||
import type React from 'react';
|
import React, { useMemo, useRef, useState } from 'react';
|
||||||
import { useMemo, useRef, useState } from 'react';
|
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
|
|
||||||
const ROW_HEIGHT = 36;
|
const ROW_HEIGHT = 36;
|
||||||
@@ -107,9 +106,7 @@ export function OverviewListModal<T extends OverviewListItem>({
|
|||||||
// Calculate totals and check for revenue
|
// Calculate totals and check for revenue
|
||||||
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
|
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
|
||||||
useMemo(() => {
|
useMemo(() => {
|
||||||
const maxSessions = Math.max(
|
const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
|
||||||
...filteredData.map((item) => item.sessions),
|
|
||||||
);
|
|
||||||
const totalRevenue = filteredData.reduce(
|
const totalRevenue = filteredData.reduce(
|
||||||
(sum, item) => sum + (item.revenue ?? 0),
|
(sum, item) => sum + (item.revenue ?? 0),
|
||||||
0,
|
0,
|
||||||
@@ -155,8 +152,7 @@ export function OverviewListModal<T extends OverviewListItem>({
|
|||||||
<div
|
<div
|
||||||
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
|
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns:
|
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||||
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-left truncate">{columnName}</div>
|
<div className="text-left truncate">{columnName}</div>
|
||||||
@@ -208,14 +204,11 @@ export function OverviewListModal<T extends OverviewListItem>({
|
|||||||
<div
|
<div
|
||||||
className="relative grid h-full items-center px-4 border-b border-border"
|
className="relative grid h-full items-center px-4 border-b border-border"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns:
|
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
||||||
`1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Main content cell */}
|
{/* Main content cell */}
|
||||||
<div className="min-w-0 truncate pr-2">
|
<div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
|
||||||
{renderItem(item)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Revenue cell */}
|
{/* Revenue cell */}
|
||||||
{hasRevenue && (
|
{hasRevenue && (
|
||||||
@@ -268,3 +261,4 @@ export function OverviewListModal<T extends OverviewListItem>({
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ export function OverviewMetricCardNumber({
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('min-w-0 col gap-2', className)}>
|
<div className={cn('min-w-0 col gap-2 items-start', className)}>
|
||||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
<div className="flex min-w-0 items-center gap-2 text-left">
|
||||||
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
|
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
|
||||||
{label}
|
{label}
|
||||||
@@ -219,7 +219,7 @@ export function OverviewMetricCardNumber({
|
|||||||
<Skeleton className="h-6 w-12" />
|
<Skeleton className="h-6 w-12" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="truncate font-mono text-3xl leading-[1.1] font-bold w-full text-left">
|
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ export default function OverviewTopDevices({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const data = query.data ?? [];
|
const data = (query.data ?? []).slice(0, 15);
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,12 +118,12 @@ export default function OverviewTopEvents({
|
|||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return tableData;
|
return tableData.slice(0, 15);
|
||||||
}
|
}
|
||||||
const queryLower = searchQuery.toLowerCase();
|
const queryLower = searchQuery.toLowerCase();
|
||||||
return tableData.filter((item) =>
|
return tableData
|
||||||
item.name?.toLowerCase().includes(queryLower),
|
.filter((item) => item.name?.toLowerCase().includes(queryLower))
|
||||||
);
|
.slice(0, 15);
|
||||||
}, [tableData, searchQuery]);
|
}, [tableData, searchQuery]);
|
||||||
|
|
||||||
const tabs = useMemo(
|
const tabs = useMemo(
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const data = query.data ?? [];
|
const data = (query.data ?? []).slice(0, 15);
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const data = query.data ?? [];
|
const data = query.data?.slice(0, 15) ?? [];
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export default function OverviewTopSources({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
const data = query.data ?? [];
|
const data = (query.data ?? []).slice(0, 15);
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
|||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { ChevronDown, ChevronUp, ExternalLinkIcon } from 'lucide-react';
|
import { ExternalLinkIcon } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
|
||||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
import { Skeleton } from '../skeleton';
|
import { Skeleton } from '../skeleton';
|
||||||
import { Tooltiper } from '../ui/tooltip';
|
import { Tooltiper } from '../ui/tooltip';
|
||||||
@@ -46,42 +45,6 @@ function RevenuePieChart({ percentage }: { percentage: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableHeader({
|
|
||||||
name,
|
|
||||||
isSorted,
|
|
||||||
sortDirection,
|
|
||||||
onClick,
|
|
||||||
isRightAligned,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
isSorted: boolean;
|
|
||||||
sortDirection: 'asc' | 'desc' | null;
|
|
||||||
onClick: () => void;
|
|
||||||
isRightAligned?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClick}
|
|
||||||
className={cn(
|
|
||||||
'row items-center gap-1 hover:opacity-80 transition-opacity',
|
|
||||||
isRightAligned && 'justify-end ml-auto',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span>{name}</span>
|
|
||||||
{isSorted ? (
|
|
||||||
sortDirection === 'desc' ? (
|
|
||||||
<ChevronDown className="size-3" />
|
|
||||||
) : (
|
|
||||||
<ChevronUp className="size-3" />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="size-3 opacity-30" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type Props<T> = WidgetTableProps<T> & {
|
type Props<T> = WidgetTableProps<T> & {
|
||||||
getColumnPercentage: (item: T) => number;
|
getColumnPercentage: (item: T) => number;
|
||||||
};
|
};
|
||||||
@@ -93,113 +56,10 @@ export const OverviewWidgetTable = <T,>({
|
|||||||
getColumnPercentage,
|
getColumnPercentage,
|
||||||
className,
|
className,
|
||||||
}: Props<T>) => {
|
}: Props<T>) => {
|
||||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc' | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle column header click for sorting
|
|
||||||
const handleSort = (columnName: string) => {
|
|
||||||
if (sortColumn === columnName) {
|
|
||||||
// Cycle through: desc -> asc -> null
|
|
||||||
if (sortDirection === 'desc') {
|
|
||||||
setSortDirection('asc');
|
|
||||||
} else if (sortDirection === 'asc') {
|
|
||||||
setSortColumn(null);
|
|
||||||
setSortDirection(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// First click on a column = descending (highest to lowest)
|
|
||||||
setSortColumn(columnName);
|
|
||||||
setSortDirection('desc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sort data based on current sort state
|
|
||||||
// Sort all available items, then limit display to top 15
|
|
||||||
const sortedData = useMemo(() => {
|
|
||||||
const allData = data ?? [];
|
|
||||||
|
|
||||||
if (!sortColumn || !sortDirection) {
|
|
||||||
// When not sorting, return top 15 (maintain original behavior)
|
|
||||||
return allData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const column = columns.find((col) => {
|
|
||||||
if (typeof col.name === 'string') {
|
|
||||||
return col.name === sortColumn;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!column?.getSortValue) {
|
|
||||||
return allData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort all available items
|
|
||||||
const sorted = [...allData].sort((a, b) => {
|
|
||||||
const aValue = column.getSortValue!(a);
|
|
||||||
const bValue = column.getSortValue!(b);
|
|
||||||
|
|
||||||
// Handle null values
|
|
||||||
if (aValue === null && bValue === null) return 0;
|
|
||||||
if (aValue === null) return 1;
|
|
||||||
if (bValue === null) return -1;
|
|
||||||
|
|
||||||
// Compare values
|
|
||||||
let comparison = 0;
|
|
||||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
|
||||||
comparison = aValue - bValue;
|
|
||||||
} else {
|
|
||||||
comparison = String(aValue).localeCompare(String(bValue));
|
|
||||||
}
|
|
||||||
|
|
||||||
return sortDirection === 'desc' ? -comparison : comparison;
|
|
||||||
});
|
|
||||||
|
|
||||||
return sorted;
|
|
||||||
}, [data, sortColumn, sortDirection, columns]).slice(0, 15);
|
|
||||||
|
|
||||||
// Create columns with sortable headers
|
|
||||||
const columnsWithSortableHeaders = useMemo(() => {
|
|
||||||
return columns.map((column, index) => {
|
|
||||||
const columnName =
|
|
||||||
typeof column.name === 'string' ? column.name : String(column.name);
|
|
||||||
const isSortable = !!column.getSortValue;
|
|
||||||
const isSorted = sortColumn === columnName;
|
|
||||||
const currentSortDirection = isSorted ? sortDirection : null;
|
|
||||||
const isRightAligned = index !== 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...column,
|
|
||||||
// Add a key property for React keys (using the original column name string)
|
|
||||||
key: columnName,
|
|
||||||
name: isSortable ? (
|
|
||||||
<SortableHeader
|
|
||||||
name={columnName}
|
|
||||||
isSorted={isSorted}
|
|
||||||
sortDirection={currentSortDirection}
|
|
||||||
onClick={() => handleSort(columnName)}
|
|
||||||
isRightAligned={isRightAligned}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
column.name
|
|
||||||
),
|
|
||||||
className: cn(
|
|
||||||
index === 0
|
|
||||||
? 'text-left w-full font-medium min-w-0'
|
|
||||||
: 'text-right font-mono',
|
|
||||||
// Remove old responsive logic - now handled by responsive prop
|
|
||||||
column.className,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [columns, sortColumn, sortDirection]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(className)}>
|
<div className={cn(className)}>
|
||||||
<WidgetTable
|
<WidgetTable
|
||||||
data={sortedData}
|
data={data ?? []}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
className={'text-sm min-h-[358px] @container'}
|
className={'text-sm min-h-[358px] @container'}
|
||||||
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
|
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
|
||||||
@@ -215,7 +75,18 @@ export const OverviewWidgetTable = <T,>({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
columns={columnsWithSortableHeaders}
|
columns={columns.map((column, index) => {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
className: cn(
|
||||||
|
index === 0
|
||||||
|
? 'text-left w-full font-medium min-w-0'
|
||||||
|
: 'text-right font-mono',
|
||||||
|
// Remove old responsive logic - now handled by responsive prop
|
||||||
|
column.className,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -337,8 +208,6 @@ export function OverviewWidgetTablePages({
|
|||||||
name: 'Revenue',
|
name: 'Revenue',
|
||||||
width: '100px',
|
width: '100px',
|
||||||
responsive: { priority: 3 }, // Always show if possible
|
responsive: { priority: 3 }, // Always show if possible
|
||||||
getSortValue: (item: (typeof data)[number]) =>
|
|
||||||
item.revenue ?? 0,
|
|
||||||
render(item: (typeof data)[number]) {
|
render(item: (typeof data)[number]) {
|
||||||
const revenue = item.revenue ?? 0;
|
const revenue = item.revenue ?? 0;
|
||||||
const revenuePercentage =
|
const revenuePercentage =
|
||||||
@@ -362,7 +231,6 @@ export function OverviewWidgetTablePages({
|
|||||||
name: 'Views',
|
name: 'Views',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 }, // Always show if possible
|
responsive: { priority: 2 }, // Always show if possible
|
||||||
getSortValue: (item: (typeof data)[number]) => item.pageviews,
|
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -377,7 +245,6 @@ export function OverviewWidgetTablePages({
|
|||||||
name: 'Sess.',
|
name: 'Sess.',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 }, // Always show if possible
|
responsive: { priority: 2 }, // Always show if possible
|
||||||
getSortValue: (item: (typeof data)[number]) => item.sessions,
|
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -472,8 +339,6 @@ export function OverviewWidgetTableEntries({
|
|||||||
name: 'Revenue',
|
name: 'Revenue',
|
||||||
width: '100px',
|
width: '100px',
|
||||||
responsive: { priority: 3 }, // Always show if possible
|
responsive: { priority: 3 }, // Always show if possible
|
||||||
getSortValue: (item: (typeof data)[number]) =>
|
|
||||||
item.revenue ?? 0,
|
|
||||||
render(item: (typeof data)[number]) {
|
render(item: (typeof data)[number]) {
|
||||||
const revenue = item.revenue ?? 0;
|
const revenue = item.revenue ?? 0;
|
||||||
const revenuePercentage =
|
const revenuePercentage =
|
||||||
@@ -497,7 +362,6 @@ export function OverviewWidgetTableEntries({
|
|||||||
name: lastColumnName,
|
name: lastColumnName,
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 }, // Always show if possible
|
responsive: { priority: 2 }, // Always show if possible
|
||||||
getSortValue: (item: (typeof data)[number]) => item.sessions,
|
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -630,9 +494,6 @@ export function OverviewWidgetTableGeneric({
|
|||||||
name: 'Revenue',
|
name: 'Revenue',
|
||||||
width: '100px',
|
width: '100px',
|
||||||
responsive: { priority: 3 },
|
responsive: { priority: 3 },
|
||||||
getSortValue: (
|
|
||||||
item: RouterOutputs['overview']['topGeneric'][number],
|
|
||||||
) => item.revenue ?? 0,
|
|
||||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||||
const revenue = item.revenue ?? 0;
|
const revenue = item.revenue ?? 0;
|
||||||
const revenuePercentage =
|
const revenuePercentage =
|
||||||
@@ -660,9 +521,6 @@ export function OverviewWidgetTableGeneric({
|
|||||||
name: 'Views',
|
name: 'Views',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 },
|
responsive: { priority: 2 },
|
||||||
getSortValue: (
|
|
||||||
item: RouterOutputs['overview']['topGeneric'][number],
|
|
||||||
) => item.pageviews,
|
|
||||||
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
render(item: RouterOutputs['overview']['topGeneric'][number]) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -679,9 +537,6 @@ export function OverviewWidgetTableGeneric({
|
|||||||
name: 'Sess.',
|
name: 'Sess.',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 },
|
responsive: { priority: 2 },
|
||||||
getSortValue: (
|
|
||||||
item: RouterOutputs['overview']['topGeneric'][number],
|
|
||||||
) => item.sessions,
|
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
@@ -744,7 +599,6 @@ export function OverviewWidgetTableEvents({
|
|||||||
name: 'Count',
|
name: 'Count',
|
||||||
width: '84px',
|
width: '84px',
|
||||||
responsive: { priority: 2 },
|
responsive: { priority: 2 },
|
||||||
getSortValue: (item: EventTableItem) => item.count,
|
|
||||||
render(item) {
|
render(item) {
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2 justify-end">
|
<div className="row gap-2 justify-end">
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
format,
|
format,
|
||||||
formatISO,
|
formatISO,
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
isToday,
|
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
subMonths,
|
subMonths,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
@@ -19,26 +18,15 @@ import {
|
|||||||
WidgetTitle,
|
WidgetTitle,
|
||||||
} from '../overview/overview-widget';
|
} from '../overview/overview-widget';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Tooltiper } from '../ui/tooltip';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: { count: number; date: string }[];
|
data: { count: number; date: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function getOpacityLevel(count: number, maxCount: number): number {
|
|
||||||
if (count === 0 || maxCount === 0) return 0;
|
|
||||||
const ratio = count / maxCount;
|
|
||||||
if (ratio <= 0.25) return 0.25;
|
|
||||||
if (ratio <= 0.5) return 0.5;
|
|
||||||
if (ratio <= 0.75) return 0.75;
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MonthCalendar = ({
|
const MonthCalendar = ({
|
||||||
month,
|
month,
|
||||||
data,
|
data,
|
||||||
maxCount,
|
}: { month: Date; data: Props['data'] }) => (
|
||||||
}: { month: Date; data: Props['data']; maxCount: number }) => (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div>
|
<div className="mb-2 text-sm">{format(month, 'MMMM yyyy')}</div>
|
||||||
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
|
<div className="-m-1 grid grid-cols-7 gap-1 p-1">
|
||||||
@@ -49,42 +37,14 @@ const MonthCalendar = ({
|
|||||||
const hit = data.find((item) =>
|
const hit = data.find((item) =>
|
||||||
item.date.includes(formatISO(date, { representation: 'date' })),
|
item.date.includes(formatISO(date, { representation: 'date' })),
|
||||||
);
|
);
|
||||||
const opacity = hit ? getOpacityLevel(hit.count, maxCount) : 0;
|
|
||||||
return (
|
return (
|
||||||
<Tooltiper
|
<div
|
||||||
key={date.toISOString()}
|
key={date.toISOString()}
|
||||||
asChild
|
className={cn(
|
||||||
content={
|
'aspect-square w-full rounded',
|
||||||
<div className="text-sm col gap-1">
|
hit ? 'bg-highlight' : 'bg-def-200',
|
||||||
<div className="font-medium">{format(date, 'EEEE, MMM d')}</div>
|
)}
|
||||||
{hit ? (
|
/>
|
||||||
<div className="text-muted-foreground">
|
|
||||||
{hit.count} {hit.count === 1 ? 'event' : 'events'}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-muted-foreground">No activity</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'aspect-square w-full rounded cursor-default group hover:ring-1 hover:ring-foreground overflow-hidden',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'size-full group-hover:shadow-[inset_0_0_0_2px_var(--background)] rounded',
|
|
||||||
isToday(date)
|
|
||||||
? 'bg-highlight'
|
|
||||||
: hit
|
|
||||||
? 'bg-foreground'
|
|
||||||
: 'bg-def-200',
|
|
||||||
)}
|
|
||||||
style={hit && !isToday(date) ? { opacity } : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tooltiper>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +53,6 @@ const MonthCalendar = ({
|
|||||||
|
|
||||||
export const ProfileActivity = ({ data }: Props) => {
|
export const ProfileActivity = ({ data }: Props) => {
|
||||||
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
|
const [startDate, setStartDate] = useState(startOfMonth(new Date()));
|
||||||
const maxCount = Math.max(...data.map((item) => item.count), 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Widget className="w-full">
|
<Widget className="w-full">
|
||||||
@@ -124,7 +83,6 @@ export const ProfileActivity = ({ data }: Props) => {
|
|||||||
key={offset}
|
key={offset}
|
||||||
month={subMonths(startDate, offset)}
|
month={subMonths(startDate, offset)}
|
||||||
data={data}
|
data={data}
|
||||||
maxCount={maxCount}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function useColumns(type: 'profiles' | 'power-users') {
|
|||||||
const profile = row.original;
|
const profile = row.original;
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
href={`/profiles/${profile.id}`}
|
||||||
className="flex items-center gap-2 font-medium"
|
className="flex items-center gap-2 font-medium"
|
||||||
title={getProfileName(profile, false)}
|
title={getProfileName(profile, false)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -11,12 +12,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportAreaChart() {
|
export function ReportAreaChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(
|
trpc.chart.chart.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
shareId,
|
||||||
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
@@ -11,12 +12,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportBarChart() {
|
export function ReportBarChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.aggregate.queryOptions(
|
trpc.chart.aggregate.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
shareId,
|
||||||
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
@@ -13,11 +14,18 @@ import { Summary } from './summary';
|
|||||||
export function ReportConversionChart() {
|
export function ReportConversionChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
console.log(report.limit);
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.conversion.queryOptions(
|
trpc.chart.conversion.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
shareId,
|
||||||
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -11,12 +12,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportHistogramChart() {
|
export function ReportHistogramChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(
|
trpc.chart.chart.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
shareId,
|
||||||
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -11,11 +13,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportLineChart() {
|
export function ReportLineChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(
|
trpc.chart.chart.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
shareId,
|
||||||
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -11,12 +12,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportMapChart() {
|
export function ReportMapChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(
|
trpc.chart.chart.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
shareId,
|
||||||
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -10,12 +11,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportMetricChart() {
|
export function ReportMetricChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.chart.queryOptions(
|
trpc.chart.chart.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
shareId,
|
||||||
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
@@ -11,12 +12,18 @@ import { Chart } from './chart';
|
|||||||
export function ReportPieChart() {
|
export function ReportPieChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
const { isLazyLoading, report, shareId } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const { range, startDate, endDate, interval } = useOverviewOptions();
|
||||||
|
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.aggregate.queryOptions(
|
trpc.chart.aggregate.queryOptions(
|
||||||
{
|
{
|
||||||
...report,
|
...report,
|
||||||
shareId,
|
shareId,
|
||||||
|
reportId: 'id' in report ? report.id : undefined,
|
||||||
|
range: range ?? report.range,
|
||||||
|
startDate: startDate ?? report.startDate,
|
||||||
|
endDate: endDate ?? report.endDate,
|
||||||
|
interval: interval ?? report.interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function ReportRetentionChart() {
|
|||||||
criteria,
|
criteria,
|
||||||
interval: overviewInterval ?? interval,
|
interval: overviewInterval ?? interval,
|
||||||
shareId,
|
shareId,
|
||||||
id,
|
reportId: id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function useColumns() {
|
|||||||
if (session.profile) {
|
if (session.profile) {
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(session.profile.id)}`}
|
href={`/profiles/${session.profile.id}`}
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
>
|
>
|
||||||
{getProfileName(session.profile)}
|
{getProfileName(session.profile)}
|
||||||
@@ -71,7 +71,7 @@ export function useColumns() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
href={`/profiles/${encodeURIComponent(session.profileId)}`}
|
href={`/profiles/${session.profileId}`}
|
||||||
className="font-mono font-medium"
|
className="font-mono font-medium"
|
||||||
>
|
>
|
||||||
{session.profileId}
|
{session.profileId}
|
||||||
|
|||||||
@@ -29,14 +29,6 @@ export interface Props<T> {
|
|||||||
* If not provided, column is always visible.
|
* If not provided, column is always visible.
|
||||||
*/
|
*/
|
||||||
responsive?: ColumnResponsive;
|
responsive?: ColumnResponsive;
|
||||||
/**
|
|
||||||
* Function to extract sortable value. If provided, header becomes clickable.
|
|
||||||
*/
|
|
||||||
getSortValue?: (item: T) => number | string | null;
|
|
||||||
/**
|
|
||||||
* Optional key for React keys. If not provided, will try to extract from name or use index.
|
|
||||||
*/
|
|
||||||
key?: string;
|
|
||||||
}[];
|
}[];
|
||||||
keyExtractor: (item: T) => string;
|
keyExtractor: (item: T) => string;
|
||||||
data: T[];
|
data: T[];
|
||||||
@@ -185,16 +177,9 @@ export function WidgetTable<T>({
|
|||||||
dataAttrs['data-min-width'] = String(column.responsive.minWidth);
|
dataAttrs['data-min-width'] = String(column.responsive.minWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use column.key if available, otherwise try to extract string from name, fallback to index
|
|
||||||
const columnKey =
|
|
||||||
column.key ??
|
|
||||||
(typeof column.name === 'string'
|
|
||||||
? column.name
|
|
||||||
: `col-${colIndex}`);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={columnKey}
|
key={column.name?.toString()}
|
||||||
className={cn(
|
className={cn(
|
||||||
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
|
'p-2 font-medium font-sans text-sm whitespace-nowrap cell',
|
||||||
columns.length > 1 && column !== columns[0]
|
columns.length > 1 && column !== columns[0]
|
||||||
@@ -246,16 +231,9 @@ export function WidgetTable<T>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use column.key if available, otherwise try to extract string from name, fallback to index
|
|
||||||
const columnKey =
|
|
||||||
column.key ??
|
|
||||||
(typeof column.name === 'string'
|
|
||||||
? column.name
|
|
||||||
: `col-${colIndex}`);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={columnKey}
|
key={column.name?.toString()}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 relative cell',
|
'px-2 relative cell',
|
||||||
columns.length > 1 && column !== columns[0]
|
columns.length > 1 && column !== columns[0]
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ function EventDetailsContent({ id, createdAt, projectId }: Props) {
|
|||||||
{profile && (
|
{profile && (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
onClick={() => popModal()}
|
onClick={() => popModal()}
|
||||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
href={`/profiles/${profile.id}`}
|
||||||
className="card p-4 py-2 col gap-2 hover:bg-def-100"
|
className="card p-4 py-2 col gap-2 hover:bg-def-100"
|
||||||
>
|
>
|
||||||
<div className="row items-center gap-2 justify-between">
|
<div className="row items-center gap-2 justify-between">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const ProfileItem = ({ profile }: { profile: any }) => {
|
|||||||
return (
|
return (
|
||||||
<ProjectLink
|
<ProjectLink
|
||||||
preload={false}
|
preload={false}
|
||||||
href={`/profiles/${encodeURIComponent(profile.id)}`}
|
href={`/profiles/${profile.id}`}
|
||||||
title={getProfileName(profile, false)}
|
title={getProfileName(profile, false)}
|
||||||
className="col gap-2 rounded-lg border p-2 bg-card"
|
className="col gap-2 rounded-lg border p-2 bg-card"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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'
|
||||||
@@ -151,11 +150,6 @@ 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',
|
||||||
@@ -573,7 +567,6 @@ 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
|
||||||
@@ -643,7 +636,6 @@ 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
|
||||||
@@ -710,7 +702,6 @@ 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
|
||||||
@@ -791,7 +782,6 @@ 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,7 +851,6 @@ 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'
|
||||||
@@ -927,7 +916,6 @@ 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'
|
||||||
@@ -1007,7 +995,6 @@ export interface RootRouteChildren {
|
|||||||
UnsubscribeRoute: typeof UnsubscribeRoute
|
UnsubscribeRoute: typeof UnsubscribeRoute
|
||||||
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
|
||||||
@@ -1081,13 +1068,6 @@ 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'
|
||||||
@@ -2025,7 +2005,6 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
UnsubscribeRoute: UnsubscribeRoute,
|
UnsubscribeRoute: UnsubscribeRoute,
|
||||||
ApiConfigRoute: ApiConfigRoute,
|
ApiConfigRoute: ApiConfigRoute,
|
||||||
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
ApiHealthcheckRoute: ApiHealthcheckRoute,
|
||||||
WidgetBadgeRoute: WidgetBadgeRoute,
|
|
||||||
WidgetCounterRoute: WidgetCounterRoute,
|
WidgetCounterRoute: WidgetCounterRoute,
|
||||||
WidgetRealtimeRoute: WidgetRealtimeRoute,
|
WidgetRealtimeRoute: WidgetRealtimeRoute,
|
||||||
WidgetTestRoute: WidgetTestRoute,
|
WidgetTestRoute: WidgetTestRoute,
|
||||||
|
|||||||
@@ -107,11 +107,6 @@ 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -374,96 +369,3 @@ 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" 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
|
|||||||
import { LoginNavbar } from '@/components/login-navbar';
|
import { LoginNavbar } from '@/components/login-navbar';
|
||||||
import { OverviewInterval } from '@/components/overview/overview-interval';
|
import { OverviewInterval } from '@/components/overview/overview-interval';
|
||||||
import { OverviewRange } from '@/components/overview/overview-range';
|
import { OverviewRange } from '@/components/overview/overview-range';
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
|
||||||
import { ReportChart } from '@/components/report-chart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||||
@@ -64,7 +63,6 @@ function RouteComponent() {
|
|||||||
const { shareId } = Route.useParams();
|
const { shareId } = Route.useParams();
|
||||||
const { header } = useSearch({ from: '/share/report/$shareId' });
|
const { header } = useSearch({ from: '/share/report/$shareId' });
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const { range, startDate, endDate, interval } = useOverviewOptions();
|
|
||||||
const shareQuery = useSuspenseQuery(
|
const shareQuery = useSuspenseQuery(
|
||||||
trpc.share.report.queryOptions({
|
trpc.share.report.queryOptions({
|
||||||
shareId,
|
shareId,
|
||||||
@@ -83,6 +81,8 @@ function RouteComponent() {
|
|||||||
|
|
||||||
const share = shareQuery.data;
|
const share = shareQuery.data;
|
||||||
|
|
||||||
|
console.log('share', share);
|
||||||
|
|
||||||
// Handle password protection
|
// Handle password protection
|
||||||
if (share.password && !hasAccess) {
|
if (share.password && !hasAccess) {
|
||||||
return <ShareEnterPassword shareId={share.id} shareType="report" />;
|
return <ShareEnterPassword shareId={share.id} shareType="report" />;
|
||||||
@@ -114,16 +114,7 @@ function RouteComponent() {
|
|||||||
<div className="font-medium text-xl">{share.report.name}</div>
|
<div className="font-medium text-xl">{share.report.name}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<ReportChart
|
<ReportChart report={share.report} shareId={shareId} />
|
||||||
report={{
|
|
||||||
...share.report,
|
|
||||||
range: range ?? share.report.range,
|
|
||||||
startDate: startDate ?? share.report.startDate,
|
|
||||||
endDate: endDate ?? share.report.endDate,
|
|
||||||
interval: interval ?? share.report.interval,
|
|
||||||
}}
|
|
||||||
shareId={shareId}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -36,11 +36,9 @@ COPY packages/queue/package.json ./packages/queue/
|
|||||||
COPY packages/logger/package.json ./packages/logger/
|
COPY packages/logger/package.json ./packages/logger/
|
||||||
COPY packages/common/package.json ./packages/common/
|
COPY packages/common/package.json ./packages/common/
|
||||||
COPY packages/importer/package.json ./packages/importer/
|
COPY packages/importer/package.json ./packages/importer/
|
||||||
COPY packages/payments/package.json ./packages/payments/
|
|
||||||
COPY packages/constants/package.json ./packages/constants/
|
COPY packages/constants/package.json ./packages/constants/
|
||||||
COPY packages/validation/package.json ./packages/validation/
|
COPY packages/validation/package.json ./packages/validation/
|
||||||
COPY packages/integrations/package.json ./packages/integrations/
|
COPY packages/integrations/package.json packages/integrations/
|
||||||
COPY packages/js-runtime/package.json ./packages/js-runtime/
|
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
|
|
||||||
# BUILD
|
# BUILD
|
||||||
@@ -87,11 +85,8 @@ COPY --from=build /app/packages/queue ./packages/queue
|
|||||||
COPY --from=build /app/packages/logger ./packages/logger
|
COPY --from=build /app/packages/logger ./packages/logger
|
||||||
COPY --from=build /app/packages/common ./packages/common
|
COPY --from=build /app/packages/common ./packages/common
|
||||||
COPY --from=build /app/packages/importer ./packages/importer
|
COPY --from=build /app/packages/importer ./packages/importer
|
||||||
COPY --from=build /app/packages/payments ./packages/payments
|
|
||||||
COPY --from=build /app/packages/constants ./packages/constants
|
|
||||||
COPY --from=build /app/packages/validation ./packages/validation
|
COPY --from=build /app/packages/validation ./packages/validation
|
||||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||||
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
|
|
||||||
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||||
RUN pnpm db:codegen
|
RUN pnpm db:codegen
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/email": "workspace:*",
|
"@openpanel/email": "workspace:*",
|
||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
"@openpanel/js-runtime": "workspace:*",
|
|
||||||
"@openpanel/json": "workspace:*",
|
"@openpanel/json": "workspace:*",
|
||||||
"@openpanel/logger": "workspace:*",
|
"@openpanel/logger": "workspace:*",
|
||||||
"@openpanel/importer": "workspace:*",
|
"@openpanel/importer": "workspace:*",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { logger as baseLogger } from '@/utils/logger';
|
import { logger as baseLogger } from '@/utils/logger';
|
||||||
import { createSessionEndJob, getSessionEnd } from '@/utils/session-handler';
|
import {
|
||||||
import { getTime, isSameDomain, parsePath } from '@openpanel/common';
|
createSessionEndJob,
|
||||||
|
createSessionStart,
|
||||||
|
getSessionEnd,
|
||||||
|
} from '@/utils/session-handler';
|
||||||
|
import { isSameDomain, parsePath } from '@openpanel/common';
|
||||||
import {
|
import {
|
||||||
getReferrerWithQuery,
|
getReferrerWithQuery,
|
||||||
parseReferrer,
|
parseReferrer,
|
||||||
@@ -189,14 +193,7 @@ export async function incomingEvent(
|
|||||||
|
|
||||||
if (!sessionEnd) {
|
if (!sessionEnd) {
|
||||||
logger.info('Creating session start event', { event: payload });
|
logger.info('Creating session start event', { event: payload });
|
||||||
await createEventAndNotify(
|
await createSessionStart({ payload }).catch((error) => {
|
||||||
{
|
|
||||||
...payload,
|
|
||||||
name: 'session_start',
|
|
||||||
createdAt: new Date(getTime(payload.createdAt) - 100),
|
|
||||||
},
|
|
||||||
logger,
|
|
||||||
).catch((error) => {
|
|
||||||
logger.error('Error creating session start event', { event: payload });
|
logger.error('Error creating session start event', { event: payload });
|
||||||
throw error;
|
throw error;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
import type { Job } from 'bullmq';
|
import type { Job } from 'bullmq';
|
||||||
|
|
||||||
import { Prisma, db } from '@openpanel/db';
|
import { db } from '@openpanel/db';
|
||||||
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
|
import { sendDiscordNotification } from '@openpanel/integrations/src/discord';
|
||||||
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
|
import { sendSlackNotification } from '@openpanel/integrations/src/slack';
|
||||||
import { execute as executeJavaScriptTemplate } from '@openpanel/js-runtime';
|
import { setSuperJson } from '@openpanel/json';
|
||||||
import type { NotificationQueuePayload } from '@openpanel/queue';
|
import type { NotificationQueuePayload } from '@openpanel/queue';
|
||||||
import { publishEvent } from '@openpanel/redis';
|
import { getRedisPub, publishEvent } from '@openpanel/redis';
|
||||||
|
|
||||||
function isValidJson<T>(
|
|
||||||
value: T | Prisma.NullableJsonNullValueInput | null | undefined,
|
|
||||||
): value is T {
|
|
||||||
return (
|
|
||||||
value !== null &&
|
|
||||||
value !== undefined &&
|
|
||||||
value !== Prisma.JsonNull &&
|
|
||||||
value !== Prisma.DbNull
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
||||||
switch (job.data.type) {
|
switch (job.data.type) {
|
||||||
@@ -25,10 +14,12 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
|||||||
|
|
||||||
if (notification.sendToApp) {
|
if (notification.sendToApp) {
|
||||||
publishEvent('notification', 'created', notification);
|
publishEvent('notification', 'created', notification);
|
||||||
|
// empty for now
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notification.sendToEmail) {
|
if (notification.sendToEmail) {
|
||||||
|
// empty for now
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,44 +33,18 @@ export async function notificationJob(job: Job<NotificationQueuePayload>) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload = notification.payload;
|
|
||||||
|
|
||||||
if (!isValidJson(payload)) {
|
|
||||||
return new Error('Invalid payload');
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (integration.config.type) {
|
switch (integration.config.type) {
|
||||||
case 'webhook': {
|
case 'webhook': {
|
||||||
let body: unknown;
|
|
||||||
|
|
||||||
if (integration.config.mode === 'javascript') {
|
|
||||||
// We only transform event payloads for now (not funnel)
|
|
||||||
if (
|
|
||||||
integration.config.javascriptTemplate &&
|
|
||||||
payload.type === 'event'
|
|
||||||
) {
|
|
||||||
const result = executeJavaScriptTemplate(
|
|
||||||
integration.config.javascriptTemplate,
|
|
||||||
payload.event,
|
|
||||||
);
|
|
||||||
body = result;
|
|
||||||
} else {
|
|
||||||
body = payload;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
body = {
|
|
||||||
title: notification.title,
|
|
||||||
message: notification.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(integration.config.url, {
|
return fetch(integration.config.url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...(integration.config.headers ?? {}),
|
...(integration.config.headers ?? {}),
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify({
|
||||||
|
title: notification.title,
|
||||||
|
message: notification.message,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'discord': {
|
case 'discord': {
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ export const SESSION_TIMEOUT = 1000 * 60 * 30;
|
|||||||
const getSessionEndJobId = (projectId: string, deviceId: string) =>
|
const getSessionEndJobId = (projectId: string, deviceId: string) =>
|
||||||
`sessionEnd:${projectId}:${deviceId}`;
|
`sessionEnd:${projectId}:${deviceId}`;
|
||||||
|
|
||||||
|
export async function createSessionStart({
|
||||||
|
payload,
|
||||||
|
}: {
|
||||||
|
payload: IServiceCreateEventPayload;
|
||||||
|
}) {
|
||||||
|
return createEvent({
|
||||||
|
...payload,
|
||||||
|
name: 'session_start',
|
||||||
|
createdAt: new Date(getTime(payload.createdAt) - 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSessionEndJob({
|
export async function createSessionEndJob({
|
||||||
payload,
|
payload,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -109,9 +109,7 @@ export const zGetTopGenericSeriesInput = zGetTopGenericInput.extend({
|
|||||||
interval: zTimeInterval,
|
interval: zTimeInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type IGetTopGenericSeriesInput = z.infer<
|
export type IGetTopGenericSeriesInput = z.infer<typeof zGetTopGenericSeriesInput> & {
|
||||||
typeof zGetTopGenericSeriesInput
|
|
||||||
> & {
|
|
||||||
timezone: string;
|
timezone: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -736,7 +734,7 @@ export class OverviewService {
|
|||||||
}>;
|
}>;
|
||||||
}> {
|
}> {
|
||||||
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
|
const prefixColumn = COLUMN_PREFIX_MAP[column] ?? null;
|
||||||
const TOP_LIMIT = 500;
|
const TOP_LIMIT = 15;
|
||||||
const fillConfig = this.getFillConfig(interval, startDate, endDate);
|
const fillConfig = this.getFillConfig(interval, startDate, endDate);
|
||||||
|
|
||||||
// Step 1: Get top 15 items
|
// Step 1: Get top 15 items
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Button as EmailButton } from '@react-email/components';
|
import { Button as EmailButton } from '@react-email/components';
|
||||||
// biome-ignore lint/style/useImportType: <explanation>
|
import type * as React from 'react';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export function Button({
|
export function Button({
|
||||||
href,
|
href,
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import {
|
|||||||
Section,
|
Section,
|
||||||
Tailwind,
|
Tailwind,
|
||||||
} from '@react-email/components';
|
} from '@react-email/components';
|
||||||
// biome-ignore lint/style/useImportType: <explanation>
|
import type React from 'react';
|
||||||
import React from 'react';
|
|
||||||
import { Footer } from './footer';
|
import { Footer } from './footer';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Text } from '@react-email/components';
|
import { Text } from '@react-email/components';
|
||||||
// biome-ignore lint/style/useImportType: <explanation>
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export function List({ items }: { items: React.ReactNode[] }) {
|
export function List({ items }: { items: React.ReactNode[] }) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './src/index';
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@openpanel/js-runtime",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"main": "index.ts",
|
|
||||||
"exports": {
|
|
||||||
".": "./index.ts"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"test": "vitest",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/parser": "^7.26.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@openpanel/tsconfig": "workspace:*",
|
|
||||||
"typescript": "catalog:",
|
|
||||||
"vitest": "^2.1.8"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import { ALLOWED_GLOBALS, ALLOWED_INSTANCE_METHODS, ALLOWED_METHODS } from './constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple recursive AST walker that doesn't require @babel/traverse
|
|
||||||
*/
|
|
||||||
export function walkNode(
|
|
||||||
node: unknown,
|
|
||||||
visitor: (
|
|
||||||
node: Record<string, unknown>,
|
|
||||||
parent?: Record<string, unknown>,
|
|
||||||
) => void,
|
|
||||||
parent?: Record<string, unknown>,
|
|
||||||
): void {
|
|
||||||
if (!node || typeof node !== 'object') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle arrays
|
|
||||||
if (Array.isArray(node)) {
|
|
||||||
for (const child of node) {
|
|
||||||
walkNode(child, visitor, parent);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodeObj = node as Record<string, unknown>;
|
|
||||||
|
|
||||||
// Only visit AST nodes (they have a 'type' property)
|
|
||||||
if (typeof nodeObj.type === 'string') {
|
|
||||||
visitor(nodeObj, parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively walk all properties
|
|
||||||
for (const key of Object.keys(nodeObj)) {
|
|
||||||
const value = nodeObj[key];
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
walkNode(value, visitor, nodeObj);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track declared variables/parameters to know what identifiers are "local"
|
|
||||||
*/
|
|
||||||
export function collectDeclaredIdentifiers(ast: unknown): Set<string> {
|
|
||||||
const declared = new Set<string>();
|
|
||||||
|
|
||||||
walkNode(ast, (node) => {
|
|
||||||
// Variable declarations: const x = ..., let y = ..., var z = ...
|
|
||||||
if (node.type === 'VariableDeclarator') {
|
|
||||||
const id = node.id as Record<string, unknown>;
|
|
||||||
if (id.type === 'Identifier') {
|
|
||||||
declared.add(id.name as string);
|
|
||||||
}
|
|
||||||
// Handle destructuring patterns
|
|
||||||
if (id.type === 'ObjectPattern') {
|
|
||||||
collectPatternIdentifiers(id, declared);
|
|
||||||
}
|
|
||||||
if (id.type === 'ArrayPattern') {
|
|
||||||
collectPatternIdentifiers(id, declared);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function parameters
|
|
||||||
if (
|
|
||||||
node.type === 'ArrowFunctionExpression' ||
|
|
||||||
node.type === 'FunctionExpression' ||
|
|
||||||
node.type === 'FunctionDeclaration'
|
|
||||||
) {
|
|
||||||
const params = node.params as Array<Record<string, unknown>>;
|
|
||||||
for (const param of params) {
|
|
||||||
collectPatternIdentifiers(param, declared);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return declared;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collect identifiers from destructuring patterns
|
|
||||||
*/
|
|
||||||
function collectPatternIdentifiers(
|
|
||||||
pattern: Record<string, unknown>,
|
|
||||||
declared: Set<string>,
|
|
||||||
): void {
|
|
||||||
if (pattern.type === 'Identifier') {
|
|
||||||
declared.add(pattern.name as string);
|
|
||||||
} else if (pattern.type === 'ObjectPattern') {
|
|
||||||
const properties = pattern.properties as Array<Record<string, unknown>>;
|
|
||||||
for (const prop of properties) {
|
|
||||||
if (prop.type === 'ObjectProperty') {
|
|
||||||
collectPatternIdentifiers(
|
|
||||||
prop.value as Record<string, unknown>,
|
|
||||||
declared,
|
|
||||||
);
|
|
||||||
} else if (prop.type === 'RestElement') {
|
|
||||||
collectPatternIdentifiers(
|
|
||||||
prop.argument as Record<string, unknown>,
|
|
||||||
declared,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (pattern.type === 'ArrayPattern') {
|
|
||||||
const elements = pattern.elements as Array<Record<string, unknown> | null>;
|
|
||||||
for (const elem of elements) {
|
|
||||||
if (elem) {
|
|
||||||
collectPatternIdentifiers(elem, declared);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (pattern.type === 'RestElement') {
|
|
||||||
collectPatternIdentifiers(
|
|
||||||
pattern.argument as Record<string, unknown>,
|
|
||||||
declared,
|
|
||||||
);
|
|
||||||
} else if (pattern.type === 'AssignmentPattern') {
|
|
||||||
// Default parameter values: (x = 5) => ...
|
|
||||||
collectPatternIdentifiers(
|
|
||||||
pattern.left as Record<string, unknown>,
|
|
||||||
declared,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an identifier is used as a property key (not a value reference)
|
|
||||||
*/
|
|
||||||
export function isPropertyKey(
|
|
||||||
node: Record<string, unknown>,
|
|
||||||
parent?: Record<string, unknown>,
|
|
||||||
): boolean {
|
|
||||||
if (!parent) return false;
|
|
||||||
|
|
||||||
// Property in object literal: { foo: value } - foo is a key
|
|
||||||
if (
|
|
||||||
parent.type === 'ObjectProperty' &&
|
|
||||||
parent.key === node &&
|
|
||||||
!parent.computed
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Property access: obj.foo - foo is a property access, not a global reference
|
|
||||||
if (
|
|
||||||
parent.type === 'MemberExpression' &&
|
|
||||||
parent.property === node &&
|
|
||||||
!parent.computed
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional chaining: obj?.foo - foo is a property access
|
|
||||||
if (
|
|
||||||
parent.type === 'OptionalMemberExpression' &&
|
|
||||||
parent.property === node &&
|
|
||||||
!parent.computed
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arrow function parameter used in callback: t => t.toUpperCase()
|
|
||||||
// The 't' in arrow function is already collected as declared identifier
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* Allowed global identifiers - super restricted allowlist
|
|
||||||
*/
|
|
||||||
export const ALLOWED_GLOBALS = new Set([
|
|
||||||
// Basic values for comparisons
|
|
||||||
'undefined',
|
|
||||||
'null',
|
|
||||||
|
|
||||||
// Type coercion functions
|
|
||||||
'parseInt',
|
|
||||||
'parseFloat',
|
|
||||||
'isNaN',
|
|
||||||
'isFinite',
|
|
||||||
|
|
||||||
// Safe built-in objects (for static methods only)
|
|
||||||
'Math',
|
|
||||||
'Date',
|
|
||||||
'JSON',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allowed methods on built-in objects (static methods)
|
|
||||||
*/
|
|
||||||
export const ALLOWED_METHODS: Record<string, Set<string>> = {
|
|
||||||
// Math methods
|
|
||||||
Math: new Set(['abs', 'ceil', 'floor', 'round', 'min', 'max', 'random']),
|
|
||||||
|
|
||||||
// JSON methods
|
|
||||||
JSON: new Set(['parse', 'stringify']),
|
|
||||||
|
|
||||||
// Date static methods
|
|
||||||
Date: new Set(['now']),
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Allowed instance methods (methods called on values, not on global objects)
|
|
||||||
* These are safe methods that can be called on any value
|
|
||||||
*/
|
|
||||||
export const ALLOWED_INSTANCE_METHODS = new Set([
|
|
||||||
// Array instance methods
|
|
||||||
'map',
|
|
||||||
'filter',
|
|
||||||
'reduce',
|
|
||||||
'reduceRight',
|
|
||||||
'forEach',
|
|
||||||
'find',
|
|
||||||
'findIndex',
|
|
||||||
'findLast',
|
|
||||||
'findLastIndex',
|
|
||||||
'some',
|
|
||||||
'every',
|
|
||||||
'includes',
|
|
||||||
'indexOf',
|
|
||||||
'lastIndexOf',
|
|
||||||
'slice',
|
|
||||||
'concat',
|
|
||||||
'join',
|
|
||||||
'flat',
|
|
||||||
'flatMap',
|
|
||||||
'sort',
|
|
||||||
'reverse',
|
|
||||||
'fill',
|
|
||||||
'at',
|
|
||||||
'with',
|
|
||||||
'toSorted',
|
|
||||||
'toReversed',
|
|
||||||
'toSpliced',
|
|
||||||
|
|
||||||
// String instance methods
|
|
||||||
'toLowerCase',
|
|
||||||
'toUpperCase',
|
|
||||||
'toLocaleLowerCase',
|
|
||||||
'toLocaleUpperCase',
|
|
||||||
'trim',
|
|
||||||
'trimStart',
|
|
||||||
'trimEnd',
|
|
||||||
'padStart',
|
|
||||||
'padEnd',
|
|
||||||
'repeat',
|
|
||||||
'replace',
|
|
||||||
'replaceAll',
|
|
||||||
'split',
|
|
||||||
'substring',
|
|
||||||
'substr',
|
|
||||||
'charAt',
|
|
||||||
'charCodeAt',
|
|
||||||
'codePointAt',
|
|
||||||
'startsWith',
|
|
||||||
'endsWith',
|
|
||||||
'match',
|
|
||||||
'matchAll',
|
|
||||||
'search',
|
|
||||||
'normalize',
|
|
||||||
'localeCompare',
|
|
||||||
|
|
||||||
// Number instance methods
|
|
||||||
'toFixed',
|
|
||||||
'toPrecision',
|
|
||||||
'toExponential',
|
|
||||||
'toLocaleString',
|
|
||||||
|
|
||||||
// Date instance methods
|
|
||||||
'getTime',
|
|
||||||
'getFullYear',
|
|
||||||
'getMonth',
|
|
||||||
'getDate',
|
|
||||||
'getDay',
|
|
||||||
'getHours',
|
|
||||||
'getMinutes',
|
|
||||||
'getSeconds',
|
|
||||||
'getMilliseconds',
|
|
||||||
'getUTCFullYear',
|
|
||||||
'getUTCMonth',
|
|
||||||
'getUTCDate',
|
|
||||||
'getUTCDay',
|
|
||||||
'getUTCHours',
|
|
||||||
'getUTCMinutes',
|
|
||||||
'getUTCSeconds',
|
|
||||||
'getUTCMilliseconds',
|
|
||||||
'getTimezoneOffset',
|
|
||||||
'toISOString',
|
|
||||||
'toJSON',
|
|
||||||
'toDateString',
|
|
||||||
'toTimeString',
|
|
||||||
'toLocaleDateString',
|
|
||||||
'toLocaleTimeString',
|
|
||||||
'valueOf',
|
|
||||||
|
|
||||||
// Object instance methods
|
|
||||||
'hasOwnProperty',
|
|
||||||
'toString',
|
|
||||||
'valueOf',
|
|
||||||
|
|
||||||
// RegExp instance methods
|
|
||||||
'test',
|
|
||||||
'exec',
|
|
||||||
|
|
||||||
// Common property access (length, etc.)
|
|
||||||
'length',
|
|
||||||
'size',
|
|
||||||
]);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* Executes a JavaScript function template
|
|
||||||
* @param code - JavaScript function code (arrow function or function expression)
|
|
||||||
* @param payload - Payload object to pass to the function
|
|
||||||
* @returns The result of executing the function
|
|
||||||
*/
|
|
||||||
export function execute(
|
|
||||||
code: string,
|
|
||||||
payload: Record<string, unknown>,
|
|
||||||
): unknown {
|
|
||||||
try {
|
|
||||||
// Create the function code that will be executed
|
|
||||||
// 'use strict' ensures 'this' is undefined (not global object)
|
|
||||||
const funcCode = `
|
|
||||||
'use strict';
|
|
||||||
return (${code})(payload);
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Create function with safe globals in scope
|
|
||||||
const func = new Function('payload', funcCode);
|
|
||||||
|
|
||||||
// Execute the function
|
|
||||||
return func(payload);
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(
|
|
||||||
`Error executing JavaScript template: ${
|
|
||||||
error instanceof Error ? error.message : String(error)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { validate } from './validate';
|
|
||||||
export { execute } from './execute';
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { execute, validate } from './index';
|
|
||||||
|
|
||||||
describe('validate', () => {
|
|
||||||
describe('Valid templates', () => {
|
|
||||||
it('should accept arrow function', () => {
|
|
||||||
const result = validate('(payload) => ({ event: payload.name })');
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject function expression', () => {
|
|
||||||
// Function expressions are not allowed - only arrow functions
|
|
||||||
const result = validate(
|
|
||||||
'(function(payload) { return { event: payload.name }; })',
|
|
||||||
);
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('arrow functions');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept complex transformations', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
event: payload.name,
|
|
||||||
user: payload.profileId,
|
|
||||||
data: payload.properties,
|
|
||||||
timestamp: new Date(payload.createdAt).toISOString()
|
|
||||||
})`;
|
|
||||||
const result = validate(code);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should accept array operations', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
tags: payload.tags?.map(t => t.toUpperCase()) || []
|
|
||||||
})`;
|
|
||||||
const result = validate(code);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Blocked operations', () => {
|
|
||||||
it('should block fetch calls', () => {
|
|
||||||
const result = validate('(payload) => fetch("https://evil.com")');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('fetch');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block XMLHttpRequest', () => {
|
|
||||||
const result = validate('(payload) => new XMLHttpRequest()');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('XMLHttpRequest');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block require calls', () => {
|
|
||||||
const result = validate('(payload) => require("fs")');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('require');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block import statements', () => {
|
|
||||||
const result = validate('import fs from "fs"; (payload) => ({})');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
// Import statements cause multiple statements error first
|
|
||||||
expect(result.error).toContain('single function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block eval calls', () => {
|
|
||||||
const result = validate('(payload) => eval("evil")');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('eval');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block setTimeout', () => {
|
|
||||||
const result = validate('(payload) => setTimeout(() => {}, 1000)');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('setTimeout');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block process access', () => {
|
|
||||||
const result = validate('(payload) => process.env.SECRET');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('process');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block while loops', () => {
|
|
||||||
const result = validate(
|
|
||||||
'(payload) => { while(true) {} return payload; }',
|
|
||||||
);
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('Loops are not allowed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block for loops', () => {
|
|
||||||
const result = validate(
|
|
||||||
'(payload) => { for(let i = 0; i < 10; i++) {} return payload; }',
|
|
||||||
);
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('Loops are not allowed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block try/catch', () => {
|
|
||||||
const result = validate(
|
|
||||||
'(payload) => { try { return payload; } catch(e) {} }',
|
|
||||||
);
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('try/catch');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block async/await', () => {
|
|
||||||
const result = validate(
|
|
||||||
'async (payload) => { await something(); return payload; }',
|
|
||||||
);
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('async/await');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block classes', () => {
|
|
||||||
const result = validate('(payload) => { class Foo {} return payload; }');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('Class');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block new Array()', () => {
|
|
||||||
const result = validate('(payload) => new Array(10)');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('new Array()');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should block new Object()', () => {
|
|
||||||
const result = validate('(payload) => new Object()');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('new Object()');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow new Date()', () => {
|
|
||||||
const result = validate(
|
|
||||||
'(payload) => ({ timestamp: new Date().toISOString() })',
|
|
||||||
);
|
|
||||||
expect(result.valid).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Invalid syntax', () => {
|
|
||||||
it('should reject non-function code', () => {
|
|
||||||
const result = validate('const x = 1;');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject invalid JavaScript', () => {
|
|
||||||
const result = validate('(payload) => { invalid syntax }');
|
|
||||||
expect(result.valid).toBe(false);
|
|
||||||
expect(result.error).toContain('Parse error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('execute', () => {
|
|
||||||
const basePayload = {
|
|
||||||
name: 'page_view',
|
|
||||||
profileId: 'user-123',
|
|
||||||
country: 'US',
|
|
||||||
city: 'New York',
|
|
||||||
device: 'desktop',
|
|
||||||
os: 'Windows',
|
|
||||||
browser: 'Chrome',
|
|
||||||
longitude: -73.935242,
|
|
||||||
latitude: 40.73061,
|
|
||||||
createdAt: '2024-01-15T10:30:00Z',
|
|
||||||
properties: {
|
|
||||||
plan: 'premium',
|
|
||||||
userId: 'user-456',
|
|
||||||
metadata: {
|
|
||||||
source: 'web',
|
|
||||||
campaign: 'summer-sale',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
profile: {
|
|
||||||
firstName: 'John',
|
|
||||||
lastName: 'Doe',
|
|
||||||
email: 'john@example.com',
|
|
||||||
},
|
|
||||||
tags: ['tag1', 'tag2'],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Basic transformations', () => {
|
|
||||||
it('should execute simple arrow function', () => {
|
|
||||||
const code = '(payload) => ({ event: payload.name })';
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({ event: 'page_view' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should access nested properties', () => {
|
|
||||||
const code = '(payload) => ({ plan: payload.properties.plan })';
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({ plan: 'premium' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple properties', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
event: payload.name,
|
|
||||||
user: payload.profileId,
|
|
||||||
location: payload.city
|
|
||||||
})`;
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({
|
|
||||||
event: 'page_view',
|
|
||||||
user: 'user-123',
|
|
||||||
location: 'New York',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Date operations', () => {
|
|
||||||
it('should format dates', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
timestamp: new Date(payload.createdAt).toISOString()
|
|
||||||
})`;
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toHaveProperty('timestamp');
|
|
||||||
expect((result as { timestamp: string }).timestamp).toBe(
|
|
||||||
'2024-01-15T10:30:00.000Z',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Array operations', () => {
|
|
||||||
it('should transform arrays', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
tags: payload.tags?.map(t => t.toUpperCase()) || []
|
|
||||||
})`;
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({ tags: ['TAG1', 'TAG2'] });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should filter arrays', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
filtered: payload.tags?.filter(t => t.includes('tag1')) || []
|
|
||||||
})`;
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({ filtered: ['tag1'] });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('String operations', () => {
|
|
||||||
it('should use template literals', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
location: \`\${payload.city}, \${payload.country}\`
|
|
||||||
})`;
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({ location: 'New York, US' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use string methods', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
upperName: payload.name.toUpperCase()
|
|
||||||
})`;
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({ upperName: 'PAGE_VIEW' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Math operations', () => {
|
|
||||||
it('should use Math functions', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
roundedLng: Math.round(payload.longitude)
|
|
||||||
})`;
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({ roundedLng: -74 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Conditional logic', () => {
|
|
||||||
it('should handle ternary operators', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
plan: payload.properties?.plan || 'free'
|
|
||||||
})`;
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({ plan: 'premium' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle if conditions', () => {
|
|
||||||
const code = `(payload) => {
|
|
||||||
const result = { event: payload.name };
|
|
||||||
if (payload.properties?.plan === 'premium') {
|
|
||||||
result.plan = 'premium';
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}`;
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({ event: 'page_view', plan: 'premium' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Complex transformations', () => {
|
|
||||||
it('should handle nested object construction', () => {
|
|
||||||
const code = `(payload) => ({
|
|
||||||
event: payload.name,
|
|
||||||
user: {
|
|
||||||
id: payload.profileId,
|
|
||||||
name: \`\${payload.profile?.firstName} \${payload.profile?.lastName}\`
|
|
||||||
},
|
|
||||||
data: payload.properties,
|
|
||||||
meta: {
|
|
||||||
location: \`\${payload.city}, \${payload.country}\`,
|
|
||||||
device: payload.device
|
|
||||||
}
|
|
||||||
})`;
|
|
||||||
const result = execute(code, basePayload);
|
|
||||||
expect(result).toEqual({
|
|
||||||
event: 'page_view',
|
|
||||||
user: {
|
|
||||||
id: 'user-123',
|
|
||||||
name: 'John Doe',
|
|
||||||
},
|
|
||||||
data: basePayload.properties,
|
|
||||||
meta: {
|
|
||||||
location: 'New York, US',
|
|
||||||
device: 'desktop',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Error handling', () => {
|
|
||||||
it('should handle runtime errors gracefully', () => {
|
|
||||||
const code = '(payload) => payload.nonexistent.property';
|
|
||||||
expect(() => {
|
|
||||||
execute(code, basePayload);
|
|
||||||
}).toThrow('Error executing JavaScript template');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import { parse } from '@babel/parser';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ALLOWED_GLOBALS,
|
|
||||||
ALLOWED_INSTANCE_METHODS,
|
|
||||||
ALLOWED_METHODS,
|
|
||||||
} from './constants';
|
|
||||||
import {
|
|
||||||
collectDeclaredIdentifiers,
|
|
||||||
isPropertyKey,
|
|
||||||
walkNode,
|
|
||||||
} from './ast-walker';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a JavaScript function is safe to execute
|
|
||||||
* by checking the AST for allowed operations only (allowlist approach)
|
|
||||||
*/
|
|
||||||
export function validate(code: string): {
|
|
||||||
valid: boolean;
|
|
||||||
error?: string;
|
|
||||||
} {
|
|
||||||
if (!code || typeof code !== 'string') {
|
|
||||||
return { valid: false, error: 'Code must be a non-empty string' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Parse the code to AST
|
|
||||||
const ast = parse(code, {
|
|
||||||
sourceType: 'module',
|
|
||||||
allowReturnOutsideFunction: true,
|
|
||||||
plugins: ['typescript'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Validate root structure: must be exactly one function expression
|
|
||||||
const program = ast.program;
|
|
||||||
const body = program.body;
|
|
||||||
|
|
||||||
if (body.length === 0) {
|
|
||||||
return { valid: false, error: 'Code cannot be empty' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.length > 1) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error:
|
|
||||||
'Code must contain only a single function. Multiple statements are not allowed.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootStatement = body[0]!;
|
|
||||||
|
|
||||||
// Must be an expression statement containing a function
|
|
||||||
if (rootStatement.type !== 'ExpressionStatement') {
|
|
||||||
if (rootStatement.type === 'VariableDeclaration') {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error:
|
|
||||||
'Variable declarations (const, let, var) are not allowed. Use a direct function expression instead.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (rootStatement.type === 'FunctionDeclaration') {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error:
|
|
||||||
'Function declarations are not allowed. Use an arrow function or function expression instead: (payload) => { ... } or function(payload) { ... }',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Code must be a function expression or arrow function',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const rootExpression = rootStatement.expression;
|
|
||||||
if (rootExpression.type !== 'ArrowFunctionExpression') {
|
|
||||||
if (rootExpression.type === 'FunctionExpression') {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error:
|
|
||||||
'Function expressions are not allowed. Use arrow functions instead: (payload) => { ... }',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error: 'Code must be an arrow function, e.g.: (payload) => { ... }',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all declared identifiers (variables, parameters)
|
|
||||||
const declaredIdentifiers = collectDeclaredIdentifiers(ast);
|
|
||||||
|
|
||||||
let validationError: string | undefined;
|
|
||||||
|
|
||||||
// Walk the AST to check for allowed patterns only
|
|
||||||
walkNode(ast, (node, parent) => {
|
|
||||||
// Skip if we already found an error
|
|
||||||
if (validationError) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block import/export declarations
|
|
||||||
if (
|
|
||||||
node.type === 'ImportDeclaration' ||
|
|
||||||
node.type === 'ExportDeclaration'
|
|
||||||
) {
|
|
||||||
validationError = 'import/export statements are not allowed';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block function declarations inside the function body
|
|
||||||
// (FunctionDeclaration creates a named function, not allowed)
|
|
||||||
if (node.type === 'FunctionDeclaration') {
|
|
||||||
validationError =
|
|
||||||
'Named function declarations are not allowed inside the function body.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block loops - use array methods like .map(), .filter() instead
|
|
||||||
if (
|
|
||||||
node.type === 'WhileStatement' ||
|
|
||||||
node.type === 'DoWhileStatement' ||
|
|
||||||
node.type === 'ForStatement' ||
|
|
||||||
node.type === 'ForInStatement' ||
|
|
||||||
node.type === 'ForOfStatement'
|
|
||||||
) {
|
|
||||||
validationError =
|
|
||||||
'Loops are not allowed. Use array methods like .map(), .filter(), .reduce() instead.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block advanced/dangerous features
|
|
||||||
if (node.type === 'TryStatement') {
|
|
||||||
validationError = 'try/catch statements are not allowed';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'ThrowStatement') {
|
|
||||||
validationError = 'throw statements are not allowed';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'WithStatement') {
|
|
||||||
validationError = 'with statements are not allowed';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'ClassDeclaration' || node.type === 'ClassExpression') {
|
|
||||||
validationError = 'Class definitions are not allowed';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'AwaitExpression') {
|
|
||||||
validationError = 'async/await is not allowed';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === 'YieldExpression') {
|
|
||||||
validationError = 'Generators are not allowed';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Block 'this' keyword - arrow functions don't have their own 'this'
|
|
||||||
// but we block it entirely to prevent any scope leakage
|
|
||||||
if (node.type === 'ThisExpression') {
|
|
||||||
validationError =
|
|
||||||
"'this' keyword is not allowed. Use the payload parameter instead.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check identifiers that reference globals
|
|
||||||
if (node.type === 'Identifier') {
|
|
||||||
const name = node.name as string;
|
|
||||||
|
|
||||||
// Block 'arguments' - not available in arrow functions anyway
|
|
||||||
// but explicitly block to prevent any confusion
|
|
||||||
if (name === 'arguments') {
|
|
||||||
validationError =
|
|
||||||
"'arguments' is not allowed. Use explicit parameters instead.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if it's a property key (not a value reference)
|
|
||||||
if (isPropertyKey(node, parent)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if it's a declared local variable/parameter
|
|
||||||
if (declaredIdentifiers.has(name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's an allowed global
|
|
||||||
if (!ALLOWED_GLOBALS.has(name)) {
|
|
||||||
validationError = `Use of '${name}' is not allowed. Only safe built-in functions are permitted.`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check method calls on global objects (like Math.random, JSON.parse)
|
|
||||||
// Handles both regular calls and optional chaining (?.)
|
|
||||||
if (
|
|
||||||
node.type === 'CallExpression' ||
|
|
||||||
node.type === 'OptionalCallExpression'
|
|
||||||
) {
|
|
||||||
const callee = node.callee as Record<string, unknown>;
|
|
||||||
const isMemberExpr =
|
|
||||||
callee.type === 'MemberExpression' ||
|
|
||||||
callee.type === 'OptionalMemberExpression';
|
|
||||||
|
|
||||||
if (isMemberExpr) {
|
|
||||||
const obj = callee.object as Record<string, unknown>;
|
|
||||||
const prop = callee.property as Record<string, unknown>;
|
|
||||||
const computed = callee.computed as boolean;
|
|
||||||
|
|
||||||
// Static method call on global object: Math.random(), JSON.parse()
|
|
||||||
if (
|
|
||||||
obj.type === 'Identifier' &&
|
|
||||||
prop.type === 'Identifier' &&
|
|
||||||
!computed
|
|
||||||
) {
|
|
||||||
const objName = obj.name as string;
|
|
||||||
const methodName = prop.name as string;
|
|
||||||
|
|
||||||
// Check if it's a call on an allowed global object
|
|
||||||
if (ALLOWED_GLOBALS.has(objName) && ALLOWED_METHODS[objName]) {
|
|
||||||
if (!ALLOWED_METHODS[objName].has(methodName)) {
|
|
||||||
validationError = `Method '${objName}.${methodName}' is not allowed. Only safe methods are permitted.`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instance method call: arr.map(), str.toLowerCase(), arr?.map()
|
|
||||||
// We allow these if the method name is in ALLOWED_INSTANCE_METHODS
|
|
||||||
if (prop.type === 'Identifier' && !computed) {
|
|
||||||
const methodName = prop.name as string;
|
|
||||||
|
|
||||||
// If calling on something other than an allowed global,
|
|
||||||
// check if the method is in the allowed instance methods
|
|
||||||
if (
|
|
||||||
obj.type !== 'Identifier' ||
|
|
||||||
!ALLOWED_GLOBALS.has(obj.name as string)
|
|
||||||
) {
|
|
||||||
if (!ALLOWED_INSTANCE_METHODS.has(methodName)) {
|
|
||||||
validationError = `Method '.${methodName}()' is not allowed. Only safe methods are permitted.`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check 'new' expressions - only allow new Date()
|
|
||||||
if (node.type === 'NewExpression') {
|
|
||||||
const callee = node.callee as Record<string, unknown>;
|
|
||||||
if (callee.type === 'Identifier') {
|
|
||||||
const name = callee.name as string;
|
|
||||||
if (name !== 'Date') {
|
|
||||||
validationError = `'new ${name}()' is not allowed. Only 'new Date()' is permitted.`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (validationError) {
|
|
||||||
return { valid: false, error: validationError };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
valid: false,
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? `Parse error: ${error.message}`
|
|
||||||
: 'Unknown parse error',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@openpanel/tsconfig/base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
},
|
|
||||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
|
||||||
},
|
|
||||||
"include": ["."],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
import { db } from '@openpanel/db';
|
|
||||||
import { Polar } from '@polar-sh/sdk';
|
|
||||||
import inquirer from 'inquirer';
|
|
||||||
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
|
|
||||||
import { getSuccessUrl } from '..';
|
|
||||||
|
|
||||||
// Register the autocomplete prompt
|
|
||||||
inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
|
|
||||||
|
|
||||||
interface Answers {
|
|
||||||
isProduction: boolean;
|
|
||||||
polarApiKey: string;
|
|
||||||
productId: string;
|
|
||||||
organizationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function promptForInput() {
|
|
||||||
// Get all organizations first
|
|
||||||
const organizations = await db.organization.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 1: Collect Polar credentials first
|
|
||||||
const polarCredentials = await inquirer.prompt<{
|
|
||||||
isProduction: boolean;
|
|
||||||
polarApiKey: string;
|
|
||||||
polarOrganizationId: string;
|
|
||||||
}>([
|
|
||||||
{
|
|
||||||
type: 'list',
|
|
||||||
name: 'isProduction',
|
|
||||||
message: 'Is this for production?',
|
|
||||||
choices: [
|
|
||||||
{ name: 'Yes', value: true },
|
|
||||||
{ name: 'No', value: false },
|
|
||||||
],
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'string',
|
|
||||||
name: 'polarApiKey',
|
|
||||||
message: 'Enter your Polar API key:',
|
|
||||||
validate: (input: string) => {
|
|
||||||
if (!input) return 'API key is required';
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Step 2: Initialize Polar client and fetch products
|
|
||||||
const polar = new Polar({
|
|
||||||
accessToken: polarCredentials.polarApiKey,
|
|
||||||
server: polarCredentials.isProduction ? 'production' : 'sandbox',
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Fetching products from Polar...');
|
|
||||||
const productsResponse = await polar.products.list({
|
|
||||||
limit: 100,
|
|
||||||
isArchived: false,
|
|
||||||
sorting: ['price_amount'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const products = productsResponse.result.items;
|
|
||||||
|
|
||||||
if (products.length === 0) {
|
|
||||||
throw new Error('No products found in Polar');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Continue with product selection and organization selection
|
|
||||||
const restOfAnswers = await inquirer.prompt<{
|
|
||||||
productId: string;
|
|
||||||
organizationId: string;
|
|
||||||
}>([
|
|
||||||
{
|
|
||||||
type: 'autocomplete',
|
|
||||||
name: 'productId',
|
|
||||||
message: 'Select product:',
|
|
||||||
source: (answersSoFar: any, input = '') => {
|
|
||||||
return products
|
|
||||||
.filter(
|
|
||||||
(product) =>
|
|
||||||
product.name.toLowerCase().includes(input.toLowerCase()) ||
|
|
||||||
product.id.toLowerCase().includes(input.toLowerCase()),
|
|
||||||
)
|
|
||||||
.map((product) => {
|
|
||||||
const price = product.prices?.[0];
|
|
||||||
const priceStr =
|
|
||||||
price && 'priceAmount' in price && price.priceAmount
|
|
||||||
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
|
|
||||||
: 'No price';
|
|
||||||
return {
|
|
||||||
name: `${product.name} (${priceStr})`,
|
|
||||||
value: product.id,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'autocomplete',
|
|
||||||
name: 'organizationId',
|
|
||||||
message: 'Select organization:',
|
|
||||||
source: (answersSoFar: any, input = '') => {
|
|
||||||
return organizations
|
|
||||||
.filter(
|
|
||||||
(org) =>
|
|
||||||
org.name.toLowerCase().includes(input.toLowerCase()) ||
|
|
||||||
org.id.toLowerCase().includes(input.toLowerCase()),
|
|
||||||
)
|
|
||||||
.map((org) => ({
|
|
||||||
name: `${org.name} (${org.id})`,
|
|
||||||
value: org.id,
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...polarCredentials,
|
|
||||||
...restOfAnswers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log('Assigning existing product to organization...');
|
|
||||||
const input = await promptForInput();
|
|
||||||
|
|
||||||
const polar = new Polar({
|
|
||||||
accessToken: input.polarApiKey,
|
|
||||||
server: input.isProduction ? 'production' : 'sandbox',
|
|
||||||
});
|
|
||||||
|
|
||||||
const organization = await db.organization.findUniqueOrThrow({
|
|
||||||
where: {
|
|
||||||
id: input.organizationId,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
createdBy: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
firstName: true,
|
|
||||||
lastName: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
projects: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!organization.createdBy) {
|
|
||||||
throw new Error(
|
|
||||||
`Organization ${organization.name} does not have a creator. Cannot proceed.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = organization.createdBy;
|
|
||||||
|
|
||||||
// Fetch product details for review
|
|
||||||
const product = await polar.products.get({ id: input.productId });
|
|
||||||
const price = product.prices?.[0];
|
|
||||||
const priceStr =
|
|
||||||
price && 'priceAmount' in price && price.priceAmount
|
|
||||||
? `$${(price.priceAmount / 100).toFixed(2)}/${price.recurringInterval || 'month'}`
|
|
||||||
: 'No price';
|
|
||||||
|
|
||||||
console.log('\nReview the following settings:');
|
|
||||||
console.table({
|
|
||||||
product: product.name,
|
|
||||||
price: priceStr,
|
|
||||||
organization: organization.name,
|
|
||||||
email: user.email,
|
|
||||||
name:
|
|
||||||
[user.firstName, user.lastName].filter(Boolean).join(' ') || 'No name',
|
|
||||||
});
|
|
||||||
|
|
||||||
const { confirmed } = await inquirer.prompt([
|
|
||||||
{
|
|
||||||
type: 'confirm',
|
|
||||||
name: 'confirmed',
|
|
||||||
message: 'Do you want to proceed?',
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
console.log('Operation canceled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkoutLink = await polar.checkoutLinks.create({
|
|
||||||
paymentProcessor: 'stripe',
|
|
||||||
productId: input.productId,
|
|
||||||
allowDiscountCodes: false,
|
|
||||||
metadata: {
|
|
||||||
organizationId: organization.id,
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
successUrl: getSuccessUrl(
|
|
||||||
input.isProduction
|
|
||||||
? 'https://dashboard.openpanel.dev'
|
|
||||||
: 'http://localhost:3000',
|
|
||||||
organization.id,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('\nCheckout link created:');
|
|
||||||
console.table(checkoutLink);
|
|
||||||
console.log('\nProduct assigned successfully!');
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => db.$disconnect());
|
|
||||||
@@ -9,7 +9,6 @@ const options: RedisOptions = {
|
|||||||
export { Redis };
|
export { Redis };
|
||||||
|
|
||||||
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||||
const REDIS_QUEUE_URL = process.env.REDIS_QUEUE_URL || REDIS_URL;
|
|
||||||
|
|
||||||
export interface ExtendedRedis extends Redis {
|
export interface ExtendedRedis extends Redis {
|
||||||
getJson: <T = any>(key: string) => Promise<T | null>;
|
getJson: <T = any>(key: string) => Promise<T | null>;
|
||||||
@@ -75,9 +74,7 @@ export function getRedisCache() {
|
|||||||
let redisSub: ExtendedRedis;
|
let redisSub: ExtendedRedis;
|
||||||
export function getRedisSub() {
|
export function getRedisSub() {
|
||||||
if (!redisSub) {
|
if (!redisSub) {
|
||||||
// In multi-region setup, pub/sub uses central Redis so subscribers
|
redisSub = createRedisClient(REDIS_URL, options);
|
||||||
// in any region can receive events published from any region
|
|
||||||
redisSub = createRedisClient(REDIS_QUEUE_URL, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return redisSub;
|
return redisSub;
|
||||||
@@ -86,9 +83,7 @@ export function getRedisSub() {
|
|||||||
let redisPub: ExtendedRedis;
|
let redisPub: ExtendedRedis;
|
||||||
export function getRedisPub() {
|
export function getRedisPub() {
|
||||||
if (!redisPub) {
|
if (!redisPub) {
|
||||||
// In multi-region setup, pub/sub uses central Redis so publishers
|
redisPub = createRedisClient(REDIS_URL, options);
|
||||||
// in any region can reach subscribers in any region
|
|
||||||
redisPub = createRedisClient(REDIS_QUEUE_URL, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return redisPub;
|
return redisPub;
|
||||||
@@ -98,8 +93,7 @@ let redisQueue: ExtendedRedis;
|
|||||||
export function getRedisQueue() {
|
export function getRedisQueue() {
|
||||||
if (!redisQueue) {
|
if (!redisQueue) {
|
||||||
// Use different redis for queues (self-hosting will re-use the same redis instance)
|
// Use different redis for queues (self-hosting will re-use the same redis instance)
|
||||||
// In multi-region setup, this points to central EU Redis for queues
|
redisQueue = createRedisClient(REDIS_URL, {
|
||||||
redisQueue = createRedisClient(REDIS_QUEUE_URL, {
|
|
||||||
...options,
|
...options,
|
||||||
enableReadyCheck: false,
|
enableReadyCheck: false,
|
||||||
maxRetriesPerRequest: null,
|
maxRetriesPerRequest: null,
|
||||||
@@ -114,8 +108,7 @@ let redisGroupQueue: ExtendedRedis;
|
|||||||
export function getRedisGroupQueue() {
|
export function getRedisGroupQueue() {
|
||||||
if (!redisGroupQueue) {
|
if (!redisGroupQueue) {
|
||||||
// Dedicated Redis connection for GroupWorker to avoid blocking BullMQ
|
// Dedicated Redis connection for GroupWorker to avoid blocking BullMQ
|
||||||
// In multi-region setup, this points to central EU Redis for queues
|
redisGroupQueue = createRedisClient(REDIS_URL, {
|
||||||
redisGroupQueue = createRedisClient(REDIS_QUEUE_URL, {
|
|
||||||
...options,
|
...options,
|
||||||
enableReadyCheck: false,
|
enableReadyCheck: false,
|
||||||
maxRetriesPerRequest: null,
|
maxRetriesPerRequest: null,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/email": "workspace:*",
|
"@openpanel/email": "workspace:*",
|
||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
"@openpanel/js-runtime": "workspace:*",
|
|
||||||
"@openpanel/payments": "workspace:^",
|
"@openpanel/payments": "workspace:^",
|
||||||
"@openpanel/redis": "workspace:*",
|
"@openpanel/redis": "workspace:*",
|
||||||
"@openpanel/validation": "workspace:*",
|
"@openpanel/validation": "workspace:*",
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ import {
|
|||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
type IChartEvent,
|
type IChartEvent,
|
||||||
|
zReportInput,
|
||||||
zChartSeries,
|
zChartSeries,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
zRange,
|
zRange,
|
||||||
zReportInput,
|
|
||||||
zTimeInterval,
|
zTimeInterval,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
zReportInput.and(
|
zReportInput.and(
|
||||||
z.object({
|
z.object({
|
||||||
shareId: z.string().optional(),
|
shareId: z.string().optional(),
|
||||||
id: z.string().optional(),
|
reportId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -347,14 +347,14 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (input.shareId) {
|
if (input.shareId) {
|
||||||
// Require reportId when shareId provided
|
// Require reportId when shareId provided
|
||||||
if (!input.id) {
|
if (!input.reportId) {
|
||||||
throw new Error('reportId required with shareId');
|
throw new Error('reportId required with shareId');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate share access
|
// Validate share access
|
||||||
const shareValidation = await validateShareAccess(
|
const shareValidation = await validateShareAccess(
|
||||||
input.shareId,
|
input.shareId,
|
||||||
input.id,
|
input.reportId,
|
||||||
{
|
{
|
||||||
cookies: ctx.cookies,
|
cookies: ctx.cookies,
|
||||||
session: ctx.session?.userId
|
session: ctx.session?.userId
|
||||||
@@ -367,7 +367,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch report and merge date overrides
|
// Fetch report and merge date overrides
|
||||||
const report = await getReportById(input.id);
|
const report = await getReportById(input.reportId);
|
||||||
if (!report) {
|
if (!report) {
|
||||||
throw TRPCAccessError('Report not found');
|
throw TRPCAccessError('Report not found');
|
||||||
}
|
}
|
||||||
@@ -420,7 +420,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
zReportInput.and(
|
zReportInput.and(
|
||||||
z.object({
|
z.object({
|
||||||
shareId: z.string().optional(),
|
shareId: z.string().optional(),
|
||||||
id: z.string().optional(),
|
reportId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -429,14 +429,14 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (input.shareId) {
|
if (input.shareId) {
|
||||||
// Require reportId when shareId provided
|
// Require reportId when shareId provided
|
||||||
if (!input.id) {
|
if (!input.reportId) {
|
||||||
throw new Error('reportId required with shareId');
|
throw new Error('reportId required with shareId');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate share access
|
// Validate share access
|
||||||
const shareValidation = await validateShareAccess(
|
const shareValidation = await validateShareAccess(
|
||||||
input.shareId,
|
input.shareId,
|
||||||
input.id,
|
input.reportId,
|
||||||
{
|
{
|
||||||
cookies: ctx.cookies,
|
cookies: ctx.cookies,
|
||||||
session: ctx.session?.userId
|
session: ctx.session?.userId
|
||||||
@@ -449,7 +449,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch report and merge date overrides
|
// Fetch report and merge date overrides
|
||||||
const report = await getReportById(input.id);
|
const report = await getReportById(input.reportId);
|
||||||
if (!report) {
|
if (!report) {
|
||||||
throw TRPCAccessError('Report not found');
|
throw TRPCAccessError('Report not found');
|
||||||
}
|
}
|
||||||
@@ -549,24 +549,23 @@ export const chartRouter = createTRPCRouter({
|
|||||||
zReportInput.and(
|
zReportInput.and(
|
||||||
z.object({
|
z.object({
|
||||||
shareId: z.string().optional(),
|
shareId: z.string().optional(),
|
||||||
id: z.string().optional(),
|
reportId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
let chartInput = input;
|
let chartInput = input;
|
||||||
console.log('input', input);
|
|
||||||
|
|
||||||
if (input.shareId) {
|
if (input.shareId) {
|
||||||
// Require reportId when shareId provided
|
// Require reportId when shareId provided
|
||||||
if (!input.id) {
|
if (!input.reportId) {
|
||||||
throw new Error('reportId required with shareId');
|
throw new Error('reportId required with shareId');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate share access
|
// Validate share access
|
||||||
const shareValidation = await validateShareAccess(
|
const shareValidation = await validateShareAccess(
|
||||||
input.shareId,
|
input.shareId,
|
||||||
input.id,
|
input.reportId,
|
||||||
ctx,
|
ctx,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -575,7 +574,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch report and merge date overrides
|
// Fetch report and merge date overrides
|
||||||
const report = await getReportById(input.id);
|
const report = await getReportById(input.reportId);
|
||||||
if (!report) {
|
if (!report) {
|
||||||
throw TRPCAccessError('Report not found');
|
throw TRPCAccessError('Report not found');
|
||||||
}
|
}
|
||||||
@@ -610,7 +609,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
zReportInput.and(
|
zReportInput.and(
|
||||||
z.object({
|
z.object({
|
||||||
shareId: z.string().optional(),
|
shareId: z.string().optional(),
|
||||||
id: z.string().optional(),
|
reportId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -619,14 +618,14 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (input.shareId) {
|
if (input.shareId) {
|
||||||
// Require reportId when shareId provided
|
// Require reportId when shareId provided
|
||||||
if (!input.id) {
|
if (!input.reportId) {
|
||||||
throw new Error('reportId required with shareId');
|
throw new Error('reportId required with shareId');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate share access
|
// Validate share access
|
||||||
const shareValidation = await validateShareAccess(
|
const shareValidation = await validateShareAccess(
|
||||||
input.shareId,
|
input.shareId,
|
||||||
input.id,
|
input.reportId,
|
||||||
{
|
{
|
||||||
cookies: ctx.cookies,
|
cookies: ctx.cookies,
|
||||||
session: ctx.session?.userId
|
session: ctx.session?.userId
|
||||||
@@ -639,7 +638,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch report and merge date overrides
|
// Fetch report and merge date overrides
|
||||||
const report = await getReportById(input.id);
|
const report = await getReportById(input.reportId);
|
||||||
if (!report) {
|
if (!report) {
|
||||||
throw TRPCAccessError('Report not found');
|
throw TRPCAccessError('Report not found');
|
||||||
}
|
}
|
||||||
@@ -681,7 +680,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
interval: zTimeInterval.default('day'),
|
interval: zTimeInterval.default('day'),
|
||||||
range: zRange,
|
range: zRange,
|
||||||
shareId: z.string().optional(),
|
shareId: z.string().optional(),
|
||||||
id: z.string().optional(),
|
reportId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
@@ -696,14 +695,14 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
if (input.shareId) {
|
if (input.shareId) {
|
||||||
// Require reportId when shareId provided
|
// Require reportId when shareId provided
|
||||||
if (!input.id) {
|
if (!input.reportId) {
|
||||||
throw new Error('reportId required with shareId');
|
throw new Error('reportId required with shareId');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate share access
|
// Validate share access
|
||||||
const shareValidation = await validateShareAccess(
|
const shareValidation = await validateShareAccess(
|
||||||
input.shareId,
|
input.shareId,
|
||||||
input.id,
|
input.reportId,
|
||||||
{
|
{
|
||||||
cookies: ctx.cookies,
|
cookies: ctx.cookies,
|
||||||
session: ctx.session?.userId
|
session: ctx.session?.userId
|
||||||
@@ -716,14 +715,13 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch report and extract events
|
// Fetch report and extract events
|
||||||
const report = await getReportById(input.id);
|
const report = await getReportById(input.reportId);
|
||||||
if (!report) {
|
if (!report) {
|
||||||
throw TRPCAccessError('Report not found');
|
throw TRPCAccessError('Report not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
projectId = report.projectId;
|
projectId = report.projectId;
|
||||||
const retentionOptions =
|
const retentionOptions = report.options?.type === 'retention' ? report.options : undefined;
|
||||||
report.options?.type === 'retention' ? report.options : undefined;
|
|
||||||
criteria = retentionOptions?.criteria ?? criteria;
|
criteria = retentionOptions?.criteria ?? criteria;
|
||||||
dateRange = input.range ?? report.range;
|
dateRange = input.range ?? report.range;
|
||||||
startDate = input.startDate ?? report.startDate;
|
startDate = input.startDate ?? report.startDate;
|
||||||
|
|||||||
@@ -10,9 +10,8 @@ import {
|
|||||||
zCreateWebhookIntegration,
|
zCreateWebhookIntegration,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { getOrganizationAccess } from '../access';
|
import { getOrganizationAccess } from '../access';
|
||||||
import { TRPCAccessError, TRPCBadRequestError } from '../errors';
|
import { TRPCAccessError } from '../errors';
|
||||||
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
import { validate as validateJavaScriptTemplate } from '@openpanel/js-runtime';
|
|
||||||
|
|
||||||
export const integrationRouter = createTRPCRouter({
|
export const integrationRouter = createTRPCRouter({
|
||||||
get: protectedProcedure
|
get: protectedProcedure
|
||||||
@@ -94,22 +93,6 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
createOrUpdate: protectedProcedure
|
createOrUpdate: protectedProcedure
|
||||||
.input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration]))
|
.input(z.union([zCreateDiscordIntegration, zCreateWebhookIntegration]))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
// Validate JavaScript template if mode is javascript
|
|
||||||
if (
|
|
||||||
input.config.type === 'webhook' &&
|
|
||||||
input.config.mode === 'javascript' &&
|
|
||||||
input.config.javascriptTemplate
|
|
||||||
) {
|
|
||||||
const validation = validateJavaScriptTemplate(
|
|
||||||
input.config.javascriptTemplate,
|
|
||||||
);
|
|
||||||
if (!validation.valid) {
|
|
||||||
throw TRPCBadRequestError(
|
|
||||||
`Invalid JavaScript template: ${validation.error}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.id) {
|
if (input.id) {
|
||||||
return db.integration.update({
|
return db.integration.update({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
eventBuffer,
|
eventBuffer,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { getCache } from '@openpanel/redis';
|
|
||||||
import {
|
import {
|
||||||
zCounterWidgetOptions,
|
zCounterWidgetOptions,
|
||||||
zRealtimeWidgetOptions,
|
zRealtimeWidgetOptions,
|
||||||
@@ -145,49 +144,6 @@ 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 }) => {
|
||||||
|
|||||||
@@ -369,8 +369,6 @@ export const zWebhookConfig = z.object({
|
|||||||
url: z.string().url(),
|
url: z.string().url(),
|
||||||
headers: z.record(z.string()),
|
headers: z.record(z.string()),
|
||||||
payload: z.record(z.string(), z.unknown()).optional(),
|
payload: z.record(z.string(), z.unknown()).optional(),
|
||||||
mode: z.enum(['message', 'javascript']).default('message'),
|
|
||||||
javascriptTemplate: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
export type IWebhookConfig = z.infer<typeof zWebhookConfig>;
|
export type IWebhookConfig = z.infer<typeof zWebhookConfig>;
|
||||||
|
|
||||||
|
|||||||
526
pnpm-lock.yaml
generated
526
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user