11"use client" ;
22
3+ import { buildEnv } from "@cap/env" ;
4+ import { Button , Switch } from "@cap/ui" ;
35import 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" ;
410import { 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" ;
718import { useEffectQuery } from "@/lib/EffectRuntime" ;
819import { useDashboardContext } from "../../Contexts" ;
920import 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
2446export 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