Skip to content

Commit f47cde1

Browse files
committed
Add Cap Pro upgrade overlay to analytics dashboard
1 parent abfbad8 commit f47cde1

2 files changed

Lines changed: 228 additions & 34 deletions

File tree

apps/web/app/(org)/dashboard/_components/DashboardInner.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default function DashboardInner({
2020
className="h-0 rounded-tl-xl border border-b-0 pointer-events-none lg:h-2 bg-gray-2 border-gray-3"
2121
/>
2222
{/* Scrolling content area shares border/background; top border removed to meet cap */}
23-
<div className="flex overflow-hidden overflow-y-auto overscroll-contain flex-col flex-1 p-5 h-full border border-t-0 bg-gray-2 border-gray-3 lg:p-8">
23+
<div className="flex overflow-hidden overflow-y-auto overscroll-contain flex-col flex-1 p-5 h-full border border-t-0 bg-gray-2 border-gray-3 lg:p-8 relative">
2424
<div className="flex flex-col flex-1 gap-4 min-h-fit">{children}</div>
2525
</div>
2626
</main>

apps/web/app/(org)/dashboard/analytics/components/AnalyticsDashboard.tsx

Lines changed: 227 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
"use client";
22

3+
import { buildEnv } from "@cap/env";
4+
import { Button, Switch } from "@cap/ui";
35
import type { Organisation } from "@cap/web-domain";
6+
import NumberFlow from "@number-flow/react";
7+
import { Fit, Layout, useRive } from "@rive-app/react-canvas";
8+
import { useMutation } from "@tanstack/react-query";
9+
import clsx from "clsx";
410
import { Effect } from "effect";
5-
import { useSearchParams } from "next/navigation";
6-
import { useEffect, useState } from "react";
11+
import { AnimatePresence, motion } from "framer-motion";
12+
import { Minus, Plus } from "lucide-react";
13+
import { useRouter, useSearchParams } from "next/navigation";
14+
import { memo, useEffect, useState } from "react";
15+
import { toast } from "sonner";
16+
import { useCurrentUser } from "@/app/Layout/AuthContext";
17+
import { useStripeContext } from "@/app/Layout/StripeContext";
718
import { useEffectQuery } from "@/lib/EffectRuntime";
819
import { useDashboardContext } from "../../Contexts";
920
import type { AnalyticsRange, OrgAnalyticsResponse } from "../types";
@@ -18,18 +29,74 @@ const RANGE_OPTIONS: { value: AnalyticsRange; label: string }[] = [
1829
{ value: "lifetime", label: "Lifetime" },
1930
];
2031

21-
const formatNumber = (value: number) =>
22-
new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
32+
const ProRiveArt = memo(() => {
33+
const { RiveComponent: ProModal } = useRive({
34+
src: "/rive/main.riv",
35+
artboard: "cap-pro-modal",
36+
animations: ["animation"],
37+
layout: new Layout({
38+
fit: Fit.Cover,
39+
}),
40+
autoplay: true,
41+
});
42+
43+
return <ProModal className="w-full h-full" />;
44+
});
2345

2446
export function AnalyticsDashboard() {
2547
const searchParams = useSearchParams();
2648
const capId = searchParams.get("capId");
49+
const user = useCurrentUser();
50+
const stripeCtx = useStripeContext();
51+
const { push } = useRouter();
2752
const { activeOrganization, organizationData, spacesData } =
2853
useDashboardContext();
2954
const [range, setRange] = useState<AnalyticsRange>("7d");
3055
const [selectedOrgId, setSelectedOrgId] =
3156
useState<Organisation.OrganisationId | null>(null);
3257
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
58+
const [isAnnual, setIsAnnual] = useState(true);
59+
const [proQuantity, setProQuantity] = useState(1);
60+
61+
const showOverlay = buildEnv.NEXT_PUBLIC_IS_CAP === "true" && !user?.isPro;
62+
const pricePerUser = isAnnual ? 8.16 : 12;
63+
const totalPrice = pricePerUser * proQuantity;
64+
const billingText = isAnnual ? "billed annually" : "billed monthly";
65+
66+
const proCheckoutMutation = useMutation({
67+
mutationFn: async () => {
68+
const planId = stripeCtx.plans[isAnnual ? "yearly" : "monthly"];
69+
70+
const response = await fetch(`/api/settings/billing/subscribe`, {
71+
method: "POST",
72+
headers: {
73+
"Content-Type": "application/json",
74+
},
75+
body: JSON.stringify({
76+
priceId: planId,
77+
quantity: proQuantity,
78+
isOnBoarding: false,
79+
}),
80+
});
81+
const data = await response.json();
82+
83+
if (data.auth === false) {
84+
localStorage.setItem("pendingPriceId", planId);
85+
localStorage.setItem("pendingQuantity", proQuantity.toString());
86+
push(`/login?next=/dashboard/analytics`);
87+
return;
88+
}
89+
90+
if (data.subscription === true) {
91+
toast.success("You are already on the Cap Pro plan");
92+
return;
93+
}
94+
95+
if (data.url) {
96+
window.location.href = data.url;
97+
}
98+
},
99+
});
33100

34101
useEffect(() => {
35102
if (activeOrganization?.organization.id && !selectedOrgId) {
@@ -99,35 +166,162 @@ export function AnalyticsDashboard() {
99166
};
100167

101168
return (
102-
<div className="space-y-8">
103-
<Header
104-
options={RANGE_OPTIONS}
105-
value={range}
106-
onChange={setRange}
107-
isLoading={query.isFetching}
108-
organizations={organizationData}
109-
activeOrganization={activeOrganization}
110-
spacesData={spacesData}
111-
selectedOrganizationId={selectedOrgId}
112-
selectedSpaceId={selectedSpaceId}
113-
onOrganizationChange={setSelectedOrgId}
114-
onSpaceChange={setSelectedSpaceId}
115-
hideCapsSelect={!!capId}
116-
capId={capId}
117-
capName={analytics?.capName ?? null}
118-
/>
119-
<StatsChart
120-
counts={{
121-
caps: analytics?.counts.caps ?? 0,
122-
views: analytics?.counts.views ?? 0,
123-
comments: analytics?.counts.comments ?? 0,
124-
reactions: analytics?.counts.reactions ?? 0,
125-
}}
126-
data={analytics?.chart ?? []}
127-
isLoading={query.isLoading}
128-
capId={capId}
129-
/>
130-
<OtherStats data={otherStats} isLoading={query.isLoading} />
169+
<div
170+
className={clsx(
171+
"relative min-h-screen",
172+
showOverlay && "overflow-hidden max-h-screen",
173+
)}
174+
>
175+
<div className="space-y-8">
176+
<Header
177+
options={RANGE_OPTIONS}
178+
value={range}
179+
onChange={setRange}
180+
isLoading={query.isFetching}
181+
organizations={organizationData}
182+
activeOrganization={activeOrganization}
183+
spacesData={spacesData}
184+
selectedOrganizationId={selectedOrgId}
185+
selectedSpaceId={selectedSpaceId}
186+
onOrganizationChange={setSelectedOrgId}
187+
onSpaceChange={setSelectedSpaceId}
188+
hideCapsSelect={!!capId}
189+
capId={capId}
190+
capName={analytics?.capName ?? null}
191+
/>
192+
<StatsChart
193+
counts={{
194+
caps: analytics?.counts.caps ?? 0,
195+
views: analytics?.counts.views ?? 0,
196+
comments: analytics?.counts.comments ?? 0,
197+
reactions: analytics?.counts.reactions ?? 0,
198+
}}
199+
data={analytics?.chart ?? []}
200+
isLoading={query.isLoading}
201+
capId={capId}
202+
/>
203+
<OtherStats data={otherStats} isLoading={query.isLoading} />
204+
</div>
205+
206+
<AnimatePresence>
207+
{showOverlay && (
208+
<motion.div
209+
initial={{ opacity: 0 }}
210+
animate={{ opacity: 1 }}
211+
exit={{ opacity: 0 }}
212+
transition={{ duration: 0.3 }}
213+
className="absolute inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-[4px] max-h-screen overflow-hidden"
214+
>
215+
<motion.div
216+
initial={{ scale: 0.95, y: -10 }}
217+
animate={{ scale: 1, y: -20 }}
218+
exit={{ scale: 0.95, y: -10 }}
219+
transition={{
220+
type: "spring",
221+
duration: 0.4,
222+
damping: 25,
223+
stiffness: 500,
224+
}}
225+
className="flex relative flex-col w-full max-w-[600px] mx-auto bg-gray-2 bg-opacity-75 backdrop-blur-md border border-gray-4 rounded-xl overflow-hidden shadow-2xl"
226+
>
227+
<div className="flex relative flex-col flex-1 justify-between items-end self-stretch">
228+
<div className="h-[150px] border-b border-gray-4 w-full overflow-hidden">
229+
<ProRiveArt />
230+
</div>
231+
<div className="flex relative flex-col flex-1 justify-center items-center py-6 w-full bg-gray-2 bg-opacity-75 backdrop-blur-md">
232+
<div className="flex flex-col items-center">
233+
<h1 className="text-3xl font-medium text-gray-12">
234+
Upgrade to unlock Cap Analytics
235+
</h1>
236+
</div>
237+
<p className="mt-1 text-lg text-center text-gray-11">
238+
You can cancel anytime. Early adopter pricing locked in.
239+
</p>
240+
241+
<div className="flex flex-col items-center mt-3 mb-4 w-full">
242+
<div className="flex flex-col items-center mb-1 sm:items-end sm:flex-row">
243+
<NumberFlow
244+
value={totalPrice}
245+
className="text-3xl font-medium tabular-nums text-gray-12"
246+
format={{
247+
style: "currency",
248+
currency: "USD",
249+
}}
250+
/>
251+
<span className="mb-2 ml-2 text-gray-11">
252+
{proQuantity === 1 ? (
253+
`per user, ${billingText}`
254+
) : (
255+
<>
256+
for{" "}
257+
<NumberFlow
258+
value={proQuantity}
259+
className="tabular-nums text-gray-12"
260+
/>{" "}
261+
users, {billingText}
262+
</>
263+
)}
264+
</span>
265+
</div>
266+
267+
<div className="flex flex-col gap-6 justify-evenly items-center mt-8 w-full max-w-md sm:gap-10 sm:flex-row">
268+
<div className="flex gap-3 items-center">
269+
<span className="text-gray-12">Annual billing</span>
270+
<Switch
271+
checked={isAnnual}
272+
onCheckedChange={() => setIsAnnual(!isAnnual)}
273+
/>
274+
</div>
275+
276+
<div className="flex items-center">
277+
<span className="mr-3 text-gray-12">Users:</span>
278+
<div className="flex items-center">
279+
<button
280+
type="button"
281+
onClick={() =>
282+
proQuantity > 1 && setProQuantity(proQuantity - 1)
283+
}
284+
className="flex justify-center items-center w-8 h-8 rounded-l-md bg-gray-4 hover:bg-gray-5"
285+
disabled={proQuantity <= 1}
286+
>
287+
<Minus className="w-4 h-4 text-gray-12" />
288+
</button>
289+
<NumberFlow
290+
value={proQuantity}
291+
className="mx-auto w-6 text-sm tabular-nums text-center text-gray-12"
292+
/>
293+
<button
294+
type="button"
295+
onClick={() => setProQuantity(proQuantity + 1)}
296+
className="flex justify-center items-center w-8 h-8 rounded-r-md bg-gray-4 hover:bg-gray-5"
297+
>
298+
<Plus className="w-4 h-4 text-gray-12" />
299+
</button>
300+
</div>
301+
</div>
302+
</div>
303+
</div>
304+
305+
<Button
306+
variant="blue"
307+
type="button"
308+
onClick={(e) => {
309+
e.preventDefault();
310+
proCheckoutMutation.mutate();
311+
}}
312+
className="mt-5 w-full max-w-sm h-14 text-lg"
313+
disabled={proCheckoutMutation.isPending}
314+
>
315+
{proCheckoutMutation.isPending
316+
? "Loading..."
317+
: "Upgrade to Cap Pro"}
318+
</Button>
319+
</div>
320+
</div>
321+
</motion.div>
322+
</motion.div>
323+
)}
324+
</AnimatePresence>
131325
</div>
132326
);
133327
}

0 commit comments

Comments
 (0)