Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .agents/skills/databuddy-internal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin
- When fixing broken dashboard links to moved sections, update the real docs/search/navigation links and section anchors directly; do not add compatibility redirect pages unless explicitly requested.
- Custom events UI is shared in `apps/dashboard/components/events/custom-events`; keep many-series legends outside the Recharts plot, use compact controls for property-summary event selection, and avoid separate event-count chip/list sections.
- Goals and Funnels are sibling conversion surfaces; keep Goals list-first and visually aligned with `app/(main)/websites/[id]/funnels` instead of adding separate summary-card chrome.
- Funnel rows keep the action menu outside the main toggle button; put row padding on the sibling `Button`, not only on `List.Row`, so the visible row surface is clickable without nesting buttons.
- Demo website navigation must be public-safe and route-backed; hide sensitive, configuration-heavy, or unavailable website features such as Agent, Feature Flags, Revenue, Users, Realtime, Anomalies, and website Settings instead of inheriting the full website nav. Goals and Funnels may be public demo surfaces, but keep them read-only.
- Dashboard definitions for feature flags and target groups are admin surfaces; do not expose even sanitized rows to demo-tier/public website access.
- Insights merged feed (`use-insights-feed`) collapses history + AI by `insightSignalDedupeKey` in `apps/dashboard/lib/insight-signal-key.ts` so the list is one row per signal (latest wins).
- Insights page (`app/(main)/insights`) should stay focused on the brief + signal queue; do not add generic global analytics KPI cards or top pages/referrers/countries tables there.
- Theme: `apps/dashboard/app/globals.css`. **`--border` is intentionally subtle**; do not crank it darker for “contrast” unless **iza** asks—prefer text tokens or layout for readability.
Expand Down
21 changes: 14 additions & 7 deletions apps/api/src/integration/cache-auth-bypass.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ describe("cache-bypass auth: target-groups.list", () => {
);
});

iit("demo caller gets sanitized rules even when authed cache exists", async () => {
iit("demo caller cannot read target groups even when authed cache exists", async () => {
const { user, org, site } = await setupOwnedSite({ isPublic: true });
await seedTargetGroup(site.id, user.id);

Expand All @@ -116,12 +116,10 @@ describe("cache-bypass auth: target-groups.list", () => {
)({ websiteId: site.id });
expect((authed[0] as { rules: unknown[] }).rules).toHaveLength(1);

const demo = await call(
appRouter.targetGroups.list,
context()
)({ websiteId: site.id });
expect(demo).toHaveLength(1);
expect((demo[0] as { rules: unknown[] }).rules).toEqual([]);
await expectCode(
call(appRouter.targetGroups.list, context())({ websiteId: site.id }),
"UNAUTHORIZED"
);
});

iit("cross-org API key cannot read a website it does not own", async () => {
Expand Down Expand Up @@ -176,6 +174,15 @@ describe("cache-bypass auth: flags.list", () => {
"FORBIDDEN"
);
});

iit("demo caller cannot read public-site flag definitions", async () => {
const { site } = await setupOwnedSite({ isPublic: true });

await expectCode(
call(appRouter.flags.list, context())({ websiteId: site.id }),
"UNAUTHORIZED"
);
});
});

describe("cache-bypass auth: annotations.list", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ interface FunnelItemProps {
onDelete: (funnelId: string) => void;
onEdit: (funnel: FunnelItemData) => void;
onToggle: (funnelId: string) => void;
readOnly?: boolean;
}

function MiniFunnelPreview({
Expand Down Expand Up @@ -94,6 +95,7 @@ export function FunnelItem({
onToggle,
onEdit,
onDelete,
readOnly = false,
className,
children,
}: FunnelItemProps) {
Expand All @@ -104,10 +106,14 @@ export function FunnelItem({
return (
<div className={cn("w-full", className)}>
<List.Row
className={cn(isExpanded && "bg-accent/30", isLast && "border-b-0")}
className={cn(
"gap-0 px-0 py-0",
isExpanded && "bg-accent/30",
isLast && "border-b-0"
)}
>
<Button
className="min-w-0 flex-1 justify-start gap-4 rounded-none bg-transparent p-0 text-left font-normal text-foreground hover:bg-transparent active:scale-100"
className="min-h-15 min-w-0 flex-1 justify-start gap-4 rounded-none bg-transparent px-4 py-3 text-left font-normal text-foreground hover:bg-transparent active:scale-100"
onClick={() => onToggle(funnel.id)}
variant="ghost"
>
Expand Down Expand Up @@ -188,38 +194,40 @@ export function FunnelItem({
</span>
</Button>

<List.Cell action>
<DropdownMenu>
<DropdownMenu.Trigger
aria-label="Funnel actions"
className="inline-flex size-8 items-center justify-center gap-1.5 rounded-md bg-transparent p-0 font-medium text-muted-foreground opacity-50 transition-all duration-(--duration-quick) ease-(--ease-smooth) hover:bg-interactive-hover hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:opacity-100"
data-dropdown-trigger
>
<DotsThreeIcon className="size-5" weight="bold" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" className="w-40">
<DropdownMenu.Item
className="gap-2"
onClick={() => onEdit(funnel)}
>
<PencilSimpleIcon className="size-4" weight="duotone" />
Edit
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
className="gap-2 text-destructive focus:text-destructive"
onClick={() => onDelete(funnel.id)}
variant="destructive"
{!readOnly && (
<List.Cell action className="flex items-center pr-4">
<DropdownMenu>
<DropdownMenu.Trigger
aria-label="Funnel actions"
className="inline-flex size-8 items-center justify-center gap-1.5 rounded-md bg-transparent p-0 font-medium text-muted-foreground opacity-50 transition-all duration-(--duration-quick) ease-(--ease-smooth) hover:bg-interactive-hover hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:opacity-100"
data-dropdown-trigger
>
<TrashIcon
className="size-4 fill-destructive"
weight="duotone"
/>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</List.Cell>
<DotsThreeIcon className="size-5" weight="bold" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" className="w-40">
<DropdownMenu.Item
className="gap-2"
onClick={() => onEdit(funnel)}
>
<PencilSimpleIcon className="size-4" weight="duotone" />
Edit
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
className="gap-2 text-destructive focus:text-destructive"
onClick={() => onDelete(funnel.id)}
variant="destructive"
>
<TrashIcon
className="size-4 fill-destructive"
weight="duotone"
/>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</List.Cell>
)}
</List.Row>

{isExpanded ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ interface FunnelsListProps {
onDeleteFunnel: (funnelId: string) => void;
onEditFunnel: (funnel: FunnelItemData) => void;
onToggleFunnel: (funnelId: string) => void;
readOnly?: boolean;
}

export function FunnelsList({
Expand All @@ -23,6 +24,7 @@ export function FunnelsList({
onToggleFunnel,
onEditFunnel,
onDeleteFunnel,
readOnly = false,
children,
}: FunnelsListProps) {
return (
Expand All @@ -38,6 +40,7 @@ export function FunnelsList({
onDelete={onDeleteFunnel}
onEdit={onEditFunnel}
onToggle={onToggleFunnel}
readOnly={readOnly}
>
{children?.(funnel)}
</FunnelItem>
Expand Down
29 changes: 18 additions & 11 deletions apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { CreateFunnelData } from "@/types/funnels";
import { cn } from "@/lib/utils";
import { GATED_FEATURES } from "@databuddy/shared/types/features";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useParams, usePathname } from "next/navigation";
import { useState } from "react";
import { TopBar } from "@/components/layout/top-bar";
import {
Expand Down Expand Up @@ -48,6 +48,8 @@ function FunnelsListSkeleton() {
export default function FunnelsPage() {
const { id } = useParams();
const websiteId = id as string;
const pathname = usePathname();
const isDemoRoute = pathname.startsWith("/demo/");
const { formattedDateRangeState, dateRange } = useDateFilters();

const [expandedId, setExpandedId] = useState<string | null>(null);
Expand Down Expand Up @@ -148,19 +150,23 @@ export default function FunnelsPage() {
className={cn("size-4 shrink-0", isFetching && "animate-spin")}
/>
</Button>
<Button onClick={() => setEditing("new")} size="sm">
<PlusIcon className="size-4 shrink-0" />
Create Funnel
</Button>
{!isDemoRoute && (
<Button onClick={() => setEditing("new")} size="sm">
<PlusIcon className="size-4 shrink-0" />
Create Funnel
</Button>
)}
</TopBar.Actions>

<div className="min-h-0 flex-1 overflow-y-auto overscroll-none">
<List.Content
emptyProps={{
action: {
label: "Create a funnel",
onClick: () => setEditing("new"),
},
action: isDemoRoute
? undefined
: {
label: "Create a funnel",
onClick: () => setEditing("new"),
},
description:
"Define a multi-step journey to see where users drop off.",
icon: <FunnelIcon className="size-6" weight="duotone" />,
Expand Down Expand Up @@ -189,6 +195,7 @@ export default function FunnelsPage() {
setExpandedId(expandedId === funnelId ? null : funnelId);
setSelectedReferrer("all");
}}
readOnly={isDemoRoute}
>
{(funnel) => {
if (expandedId !== funnel.id) {
Expand Down Expand Up @@ -220,7 +227,7 @@ export default function FunnelsPage() {
</List.Content>
</div>

{editing !== null && (
{!isDemoRoute && editing !== null && (
<EditFunnelDialog
autocompleteData={autocomplete.data}
funnel={
Expand All @@ -241,7 +248,7 @@ export default function FunnelsPage() {
/>
)}

{!!deletingId && (
{!isDemoRoute && !!deletingId && (
<DeleteDialog
confirmLabel="Delete Funnel"
isOpen={!!deletingId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface GoalItemProps {
isLoadingAnalytics?: boolean;
onDelete: (goalId: string) => void;
onEdit: (goal: Goal) => void;
readOnly?: boolean;
}

function GoalProgress({ rate }: { rate: number }) {
Expand Down Expand Up @@ -74,6 +75,7 @@ export function GoalItem({
isLoadingAnalytics,
onEdit,
onDelete,
readOnly = false,
}: GoalItemProps) {
const analyticsData = analytics?.ok ? analytics.data : null;
const analyticsError = analytics && !analytics.ok ? analytics.error : null;
Expand Down Expand Up @@ -149,32 +151,37 @@ export function GoalItem({
)}
</List.Cell>

<List.Cell action>
<DropdownMenu>
<DropdownMenu.Trigger
aria-label="Goal actions"
className="inline-flex size-8 items-center justify-center gap-1.5 rounded-md bg-transparent p-0 font-medium text-muted-foreground opacity-50 transition-all duration-(--duration-quick) ease-(--ease-smooth) hover:bg-interactive-hover hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:opacity-100"
data-dropdown-trigger
>
<DotsThreeIcon className="size-5" weight="bold" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" className="w-40">
<DropdownMenu.Item className="gap-2" onClick={() => onEdit(goal)}>
<PencilSimpleIcon className="size-4" weight="duotone" />
Edit
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
className="gap-2 text-destructive focus:text-destructive"
onClick={() => onDelete(goal.id)}
variant="destructive"
{!readOnly && (
<List.Cell action>
<DropdownMenu>
<DropdownMenu.Trigger
aria-label="Goal actions"
className="inline-flex size-8 items-center justify-center gap-1.5 rounded-md bg-transparent p-0 font-medium text-muted-foreground opacity-50 transition-all duration-(--duration-quick) ease-(--ease-smooth) hover:bg-interactive-hover hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:opacity-100"
data-dropdown-trigger
>
<TrashIcon className="size-4 fill-destructive" weight="duotone" />
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</List.Cell>
<DotsThreeIcon className="size-5" weight="bold" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" className="w-40">
<DropdownMenu.Item className="gap-2" onClick={() => onEdit(goal)}>
<PencilSimpleIcon className="size-4" weight="duotone" />
Edit
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
className="gap-2 text-destructive focus:text-destructive"
onClick={() => onDelete(goal.id)}
variant="destructive"
>
<TrashIcon
className="size-4 fill-destructive"
weight="duotone"
/>
Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
</List.Cell>
)}
</List.Row>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface GoalsListProps {
goals: Goal[];
onDeleteGoal: (goalId: string) => void;
onEditGoal: (goal: Goal) => void;
readOnly?: boolean;
}

const EMPTY_GOAL_ANALYTICS: GoalAnalyticsRecord = {};
Expand All @@ -20,6 +21,7 @@ export function GoalsList({
onDeleteGoal,
goalAnalytics = EMPTY_GOAL_ANALYTICS,
analyticsLoading = false,
readOnly = false,
}: GoalsListProps) {
return (
<List className="rounded bg-card">
Expand All @@ -34,6 +36,7 @@ export function GoalsList({
key={goal.id}
onDelete={onDeleteGoal}
onEdit={onEditGoal}
readOnly={readOnly}
/>
);
})}
Expand Down
Loading
Loading