feat: move More Pages to dialog

- Introduced `MorePagesDialog` and `MorePagesContent` components to manage and display tasks for earning additional pages.
- Integrated dialog state management using `morePagesDialogAtom`.
- Updated `LayoutDataProvider` to include the new dialog and handle task completion logic.
- Enhanced `PageUsageDisplay` to trigger the More Pages dialog, improving user interaction for page management.
This commit is contained in:
Anish Sarkar 2026-03-17 01:50:15 +05:30
parent 993c8539e8
commit 2ab00c1746
8 changed files with 259 additions and 222 deletions

View file

@ -1,80 +1,9 @@
"use client";
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, Gift, Mail, Star, Zap } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useEffect } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import {
trackIncentiveContactOpened,
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
import { MorePagesContent } from "@/components/settings/more-pages-content";
export default function MorePagesPage() {
const queryClient = useQueryClient();
useEffect(() => {
trackIncentivePageViewed();
}, []);
const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"],
queryFn: () => incentiveTasksApiService.getTasks(),
});
const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => {
if (response.success) {
toast.success(response.message);
const task = data?.tasks.find((t) => t.task_type === taskType);
if (task) {
trackIncentiveTaskCompleted(taskType, task.pages_reward);
}
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: ["user"] });
}
},
onError: () => {
toast.error("Failed to complete task. Please try again.");
},
});
const handleTaskClick = (task: IncentiveTaskInfo) => {
if (!task.completed) {
trackIncentiveTaskClicked(task.task_type);
completeMutation.mutate(task.task_type);
}
};
return (
<div className="flex min-h-[calc(100vh-64px)] select-none items-center justify-center px-4 py-8">
<motion.div
@ -83,140 +12,7 @@ export default function MorePagesPage() {
transition={{ duration: 0.3 }}
className="w-full max-w-md space-y-6"
>
{/* Header */}
<div className="text-center">
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
<p className="mt-1 text-sm text-muted-foreground">
Complete tasks to earn additional pages
</p>
</div>
{/* Tasks */}
{isLoading ? (
<Card>
<CardContent className="flex items-center gap-3 p-3">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
) : (
<div className="space-y-2">
{data?.tasks.map((task) => (
<Card
key={task.task_type}
className={cn("transition-colors", task.completed && "bg-muted/50")}
>
<CardContent className="flex items-center gap-3 p-3">
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"text-sm font-medium",
task.completed && "text-muted-foreground line-through"
)}
>
{task.title}
</p>
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
</div>
<Button
variant={task.completed ? "ghost" : "outline"}
size="sm"
disabled={task.completed || completeMutation.isPending}
onClick={() => handleTaskClick(task)}
asChild={!task.completed}
>
{task.completed ? (
<span>Done</span>
) : (
<a
href={task.action_url}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
{completeMutation.isPending ? (
<Spinner size="xs" />
) : (
<>
Go
<ExternalLink className="h-3 w-3" />
</>
)}
</a>
)}
</Button>
</CardContent>
</Card>
))}
</div>
)}
{/* PRO Upgrade */}
<Separator />
<Card className="overflow-hidden border-emerald-500/20">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-emerald-500" />
<CardTitle className="text-base">Upgrade to PRO</CardTitle>
<Badge className="bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
FREE
</Badge>
</div>
<CardDescription>
For a limited time, get{" "}
<span className="font-semibold text-foreground">6,000 additional pages</span> at no
cost. Contact us and we&apos;ll upgrade your account instantly.
</CardDescription>
</CardHeader>
<CardFooter className="pt-2">
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
<DialogTrigger asChild>
<Button className="w-full bg-emerald-600 text-white hover:bg-emerald-700">
<Mail className="h-4 w-4" />
Contact Us to Upgrade
</Button>
</DialogTrigger>
<DialogContent className="select-none sm:max-w-sm">
<DialogHeader>
<DialogTitle>Get in Touch</DialogTitle>
<DialogDescription>Pick the option that works best for you.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Button asChild>
<Link
href="https://cal.com/mod-rohan"
target="_blank"
rel="noopener noreferrer"
>
<IconCalendar className="h-4 w-4" />
Schedule a Meeting
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="mailto:rohan@surfsense.com">
<IconMailFilled className="h-4 w-4" />
rohan@surfsense.com
</Link>
</Button>
</div>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
<MorePagesContent />
</motion.div>
</div>
);

View file

@ -21,3 +21,5 @@ export const userSettingsDialogAtom = atom<UserSettingsDialogState>({
});
export const teamDialogAtom = atom<boolean>(false);
export const morePagesDialogAtom = atom<boolean>(false);

View file

@ -15,6 +15,7 @@ import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom";
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import {
morePagesDialogAtom,
searchSpaceSettingsDialogAtom,
teamDialogAtom,
userSettingsDialogAtom,
@ -40,7 +41,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
import { useAnnouncements } from "@/hooks/use-announcements";
import { useDocumentsProcessing } from "@/hooks/use-documents-processing";
import { useInbox } from "@/hooks/use-inbox";
@ -52,6 +53,7 @@ import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-pers
import { cleanupElectric } from "@/lib/electric/client";
import { resetUser, trackLogout } from "@/lib/posthog/events";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { MorePagesDialog } from "@/components/settings/more-pages-dialog";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
import { TeamDialog } from "@/components/settings/team-dialog";
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
@ -201,6 +203,8 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const seenPageLimitNotifications = useRef<Set<number>>(new Set());
const isInitialLoad = useRef(true);
const setMorePagesOpen = useSetAtom(morePagesDialogAtom);
// Effect to show toast for new page_limit_exceeded notifications
useEffect(() => {
if (statusInbox.loading) return;
@ -224,21 +228,17 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
for (const notification of newNotifications) {
seenPageLimitNotifications.current.add(notification.id);
const actionUrl = isPageLimitExceededMetadata(notification.metadata)
? notification.metadata.action_url
: `/dashboard/${searchSpaceId}/more-pages`;
toast.error(notification.title, {
description: notification.message,
duration: 8000,
icon: <AlertTriangle className="h-5 w-5 text-amber-500" />,
action: {
label: "View Plans",
onClick: () => router.push(actionUrl),
onClick: () => setMorePagesOpen(true),
},
});
}
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]);
}, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, setMorePagesOpen]);
// Delete dialogs state
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
@ -951,6 +951,7 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
<SearchSpaceSettingsDialog searchSpaceId={Number(searchSpaceId)} />
<UserSettingsDialog />
<TeamDialog searchSpaceId={Number(searchSpaceId)} />
<MorePagesDialog />
</>
);
}

View file

@ -362,6 +362,9 @@ export function LayoutShell({
<div
role="slider"
aria-label="Resize sidebar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={50}
tabIndex={0}
onMouseDown={onResizeMouseDown}
className="hidden md:block h-full cursor-col-resize z-30"

View file

@ -1,8 +1,8 @@
"use client";
import { useSetAtom } from "jotai";
import { Zap } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { morePagesDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
@ -12,8 +12,7 @@ interface PageUsageDisplayProps {
}
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
const params = useParams();
const searchSpaceId = params.search_space_id;
const setMorePagesOpen = useSetAtom(morePagesDialogAtom);
const usagePercentage = (pagesUsed / pagesLimit) * 100;
return (
@ -26,9 +25,10 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5" />
<Link
href={`/dashboard/${searchSpaceId}/more-pages`}
className="group flex items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
<button
type="button"
onClick={() => setMorePagesOpen(true)}
className="group flex w-full items-center justify-between rounded-md px-1.5 py-1 -mx-1.5 transition-colors hover:bg-accent"
>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground group-hover:text-accent-foreground">
<Zap className="h-3 w-3 shrink-0" />
@ -37,7 +37,7 @@ export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProp
<Badge className="h-4 rounded px-1 text-[10px] font-semibold leading-none bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
FREE
</Badge>
</Link>
</button>
</div>
</div>
);

View file

@ -54,7 +54,6 @@ interface SidebarProps {
isLoadingChats?: boolean;
disableTooltips?: boolean;
sidebarWidth?: number;
onResizeMouseDown?: (e: React.MouseEvent) => void;
isResizing?: boolean;
}

View file

@ -0,0 +1,212 @@
"use client";
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, Gift, Mail, Star, Zap } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import {
trackIncentiveContactOpened,
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
export function MorePagesContent() {
const queryClient = useQueryClient();
useEffect(() => {
trackIncentivePageViewed();
}, []);
const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"],
queryFn: () => incentiveTasksApiService.getTasks(),
});
const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => {
if (response.success) {
toast.success(response.message);
const task = data?.tasks.find((t) => t.task_type === taskType);
if (task) {
trackIncentiveTaskCompleted(taskType, task.pages_reward);
}
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: ["user"] });
}
},
onError: () => {
toast.error("Failed to complete task. Please try again.");
},
});
const handleTaskClick = (task: IncentiveTaskInfo) => {
if (!task.completed) {
trackIncentiveTaskClicked(task.task_type);
completeMutation.mutate(task.task_type);
}
};
return (
<div className="w-full space-y-6">
<div className="text-center">
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
<p className="mt-1 text-sm text-muted-foreground">
Complete tasks to earn additional pages
</p>
</div>
{isLoading ? (
<Card>
<CardContent className="flex items-center gap-3 p-3">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
) : (
<div className="space-y-2">
{data?.tasks.map((task) => (
<Card
key={task.task_type}
className={cn("transition-colors", task.completed && "bg-muted/50")}
>
<CardContent className="flex items-center gap-3 p-3">
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<p
className={cn(
"text-sm font-medium",
task.completed && "text-muted-foreground line-through"
)}
>
{task.title}
</p>
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
</div>
<Button
variant={task.completed ? "ghost" : "outline"}
size="sm"
disabled={task.completed || completeMutation.isPending}
onClick={() => handleTaskClick(task)}
asChild={!task.completed}
>
{task.completed ? (
<span>Done</span>
) : (
<a
href={task.action_url}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
{completeMutation.isPending ? (
<Spinner size="xs" />
) : (
<>
Go
<ExternalLink className="h-3 w-3" />
</>
)}
</a>
)}
</Button>
</CardContent>
</Card>
))}
</div>
)}
<Separator />
<Card className="overflow-hidden border-emerald-500/20 bg-transparent">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-emerald-500" />
<CardTitle className="text-base">Upgrade to PRO</CardTitle>
<Badge className="bg-emerald-600 text-white border-transparent hover:bg-emerald-600">
FREE
</Badge>
</div>
<CardDescription>
For a limited time, get{" "}
<span className="font-semibold text-foreground">6,000 additional pages</span> at no
cost. Contact us and we&apos;ll upgrade your account instantly.
</CardDescription>
</CardHeader>
<CardFooter className="pt-2">
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
<DialogTrigger asChild>
<Button className="w-full bg-emerald-600 text-white hover:bg-emerald-700">
<Mail className="h-4 w-4" />
Contact Us to Upgrade
</Button>
</DialogTrigger>
<DialogContent className="select-none sm:max-w-sm">
<DialogHeader>
<DialogTitle>Get in Touch</DialogTitle>
<DialogDescription>Pick the option that works best for you.</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
<Button asChild>
<Link
href="https://cal.com/mod-rohan"
target="_blank"
rel="noopener noreferrer"
>
<IconCalendar className="h-4 w-4" />
Schedule a Meeting
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="mailto:rohan@surfsense.com">
<IconMailFilled className="h-4 w-4" />
rohan@surfsense.com
</Link>
</Button>
</div>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
</div>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { useAtom } from "jotai";
import { morePagesDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { MorePagesContent } from "./more-pages-content";
export function MorePagesDialog() {
const [open, setOpen] = useAtom(morePagesDialogAtom);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
className="select-none max-w-md w-[95vw] max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DialogTitle className="sr-only">Get More Pages</DialogTitle>
<div className="flex-1 overflow-y-auto p-6">
<MorePagesContent />
</div>
</DialogContent>
</Dialog>
);
}