From 2be3239855076640997e8e15654e355a4c3cf9e5 Mon Sep 17 00:00:00 2001 From: Aman Raj <113578582+huamanraj@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:49:27 +0530 Subject: [PATCH 01/17] feat: posthog event tracting for analytics --- .../src/app/(main)/(landing)/pitch/page.tsx | 104 ++++-- .../src/app/(main)/(landing)/pricing/page.tsx | 1 + apps/web/src/app/layout.tsx | 3 +- apps/web/src/app/providers.tsx | 120 +++++- apps/web/src/components/dashboard/Sidebar.tsx | 55 ++- .../src/components/landing-sections/CTA.tsx | 73 ++-- .../src/components/landing-sections/Hero.tsx | 13 +- .../components/landing-sections/footer.tsx | 29 ++ .../components/landing-sections/navbar.tsx | 27 +- apps/web/src/components/login/SignInPage.tsx | 18 +- .../src/components/payment/PaymentFlow.tsx | 41 +++ apps/web/src/hooks/useAnalytics.ts | 259 +++++++++++++ apps/web/src/lib/analytics.ts | 348 ++++++++++++++++++ 13 files changed, 1027 insertions(+), 64 deletions(-) create mode 100644 apps/web/src/hooks/useAnalytics.ts create mode 100644 apps/web/src/lib/analytics.ts diff --git a/apps/web/src/app/(main)/(landing)/pitch/page.tsx b/apps/web/src/app/(main)/(landing)/pitch/page.tsx index 21b82b72..032949c5 100644 --- a/apps/web/src/app/(main)/(landing)/pitch/page.tsx +++ b/apps/web/src/app/(main)/(landing)/pitch/page.tsx @@ -106,7 +106,10 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-4" > -

+

mission statement

@@ -134,7 +137,10 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-6" > -

+

my goal

@@ -190,7 +196,10 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-6" > -

+

the plan

@@ -258,13 +267,19 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-8" > -

+

philosophies i follow

{/* Philosophy #1 */}
-

+

#1 stay small. stay effective.

@@ -303,9 +318,9 @@ const Pitch = () => {

- if i go with this approach, i'll have to sacrifice those - fancy dreams of raising millions, being on the front - page of magazines, having millions of users, etc. + if i go with this approach, i'll have to sacrifice + those fancy dreams of raising millions, being on the + front page of magazines, having millions of users, etc.



but the good part is i'll be able to stay genuine @@ -341,7 +356,10 @@ const Pitch = () => { {/* Philosophy #2 */}

-

+

#2 go beyond what you promise.

@@ -374,7 +392,10 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-6" > -

+

so how small?

@@ -425,7 +446,10 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-6" > -

+

what existing investors said about me?

@@ -458,13 +482,19 @@ const Pitch = () => { }} className="max-w-4xl mx-auto space-y-8" > -

+

questions you may have

-

+

i'm not an absolute beginner, so how does subscribing to opensox.ai make sense to me?

@@ -485,7 +515,10 @@ const Pitch = () => {
-

+

will the quality of your service reduce as you grow?

@@ -496,7 +529,10 @@ const Pitch = () => {

-

+

how does opensox.ai pro help me?

    @@ -555,7 +591,10 @@ const Pitch = () => {
-

+

how much time does it take to get the results?

@@ -572,7 +611,10 @@ const Pitch = () => {

-

+

why should i trust you?

@@ -599,7 +641,10 @@ const Pitch = () => {

-

+

are there any alternatives to what you provide?

@@ -611,7 +656,10 @@ const Pitch = () => {

-

+

what's the difference between opensox pro and a course?

@@ -624,7 +672,10 @@ const Pitch = () => {
-

+

is it for an absolute beginner?

@@ -633,7 +684,10 @@ const Pitch = () => {

-

+

in what cases shouldn't i invest in opensox pro?

@@ -649,7 +703,7 @@ const Pitch = () => {

  • - you don't wanna do it fast + you don't wanna do it fast
  • @@ -663,7 +717,10 @@ const Pitch = () => {
  • -

    +

    are you the best in the market?

    @@ -715,6 +772,7 @@ const Pitch = () => { buttonText="Invest" buttonClassName="w-full max-w-md" callbackUrl={callbackUrl} + buttonLocation="pitch_page" /> ) : ( diff --git a/apps/web/src/app/(main)/(landing)/pricing/page.tsx b/apps/web/src/app/(main)/(landing)/pricing/page.tsx index ac4feb70..2a246bbd 100644 --- a/apps/web/src/app/(main)/(landing)/pricing/page.tsx +++ b/apps/web/src/app/(main)/(landing)/pricing/page.tsx @@ -428,6 +428,7 @@ const SecondaryPricingCard = ({ callbackUrl }: { callbackUrl: string }) => { planIdOk ? "" : "opacity-60 cursor-not-allowed" }`} callbackUrl={callbackUrl} + buttonLocation="pricing_page" />

    + {children} diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index 2a7f0629..4b6bcc54 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -2,10 +2,98 @@ import posthog from "posthog-js"; import { PostHogProvider as PHProvider } from "posthog-js/react"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; +import { useSession } from "next-auth/react"; import PostHogPageView from "./PostHogPageView"; +// Session storage key to track if sign-in was initiated +const SIGN_IN_INITIATED_KEY = "posthog_sign_in_initiated"; +const SIGN_IN_PROVIDER_KEY = "posthog_sign_in_provider"; + +/** + * PostHog Auth Tracker + * + * This component must be rendered INSIDE SessionProvider. + * It tracks sign_in_completed events when user authenticates. + */ +export function PostHogAuthTracker() { + const { data: session, status } = useSession(); + const hasTrackedSignIn = useRef(false); + const previousStatus = useRef(null); + + useEffect(() => { + if (status === "loading") return; + + try { + // Check if PostHog is initialized + if (!posthog.__loaded) return; + + // Detect transition from unauthenticated to authenticated + const wasSignInInitiated = + sessionStorage.getItem(SIGN_IN_INITIATED_KEY) === "true"; + const storedProvider = sessionStorage.getItem(SIGN_IN_PROVIDER_KEY) as + | "google" + | "github" + | null; + + if (status === "authenticated" && session?.user) { + // Check if this is a fresh sign-in (not just a page refresh) + const isNewSignIn = + wasSignInInitiated || + (previousStatus.current === "unauthenticated" && + !hasTrackedSignIn.current); + + if (isNewSignIn && !hasTrackedSignIn.current) { + hasTrackedSignIn.current = true; + + // Determine provider from stored value + const provider = storedProvider || "google"; // Default to google if unknown + + // Track sign-in completed EVENT only (no person properties) + posthog.capture("sign_in_completed", { + provider: provider, + is_new_user: false, + }); + + if (process.env.NODE_ENV === "development") { + console.log("[Analytics] Event tracked: sign_in_completed", { + provider, + is_new_user: false, + }); + } + + // Clear the sign-in tracking flags + sessionStorage.removeItem(SIGN_IN_INITIATED_KEY); + sessionStorage.removeItem(SIGN_IN_PROVIDER_KEY); + } + } else if (status === "unauthenticated") { + // Reset tracking flag for next sign-in + hasTrackedSignIn.current = false; + + if (process.env.NODE_ENV === "development") { + console.log("[PostHog] User unauthenticated"); + } + } + + // Track previous status + previousStatus.current = status; + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("[PostHog] Error handling auth state:", error); + } + } + }, [session, status]); + + return null; +} + +/** + * PostHog Provider + * NOTE: This provider does NOT handle auth tracking. + * Use PostHogAuthTracker inside SessionProvider for that. + */ export function PostHogProvider({ children }: { children: React.ReactNode }) { + // Initialize PostHog useEffect(() => { const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY; const posthogHost = process.env.NEXT_PUBLIC_POSTHOG_HOST; @@ -13,11 +101,37 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { if (posthogKey && posthogHost) { posthog.init(posthogKey, { api_host: posthogHost, + + // Pageview tracking capture_pageview: false, // Disable automatic pageview capture, as we capture manually - capture_pageleave: true, + capture_pageleave: true, // Track when users leave pages + + // Privacy settings + disable_session_recording: true, // Privacy: No session recordings + respect_dnt: true, // Respect Do Not Track header + ip: false, // Do not store IP addresses (anonymize) + + // Persistence settings + persistence: "localStorage+cookie", // Persist anonymous ID across sessions + + // Performance settings + autocapture: false, // We use custom events for better control + + // Development settings + loaded: (posthog) => { + if (process.env.NODE_ENV === "development") { + console.log("[PostHog] Initialized successfully"); + // Enable debug mode in development + posthog.debug(false); // Set to true to see all PostHog logs + } + }, }); } else { - console.error("PostHog key or host is not defined"); + if (process.env.NODE_ENV === "development") { + console.warn( + "[PostHog] Key or host is not defined - analytics disabled" + ); + } } }, []); diff --git a/apps/web/src/components/dashboard/Sidebar.tsx b/apps/web/src/components/dashboard/Sidebar.tsx index afea0818..22265205 100644 --- a/apps/web/src/components/dashboard/Sidebar.tsx +++ b/apps/web/src/components/dashboard/Sidebar.tsx @@ -20,7 +20,7 @@ import { Squares2X2Icon, ChevronDownIcon, LockClosedIcon, - AcademicCapIcon + AcademicCapIcon, } from "@heroicons/react/24/outline"; import { useShowSidebar } from "@/store/useShowSidebar"; import { signOut, useSession } from "next-auth/react"; @@ -28,6 +28,7 @@ import { ProfilePic } from "./ProfilePic"; import { useSubscription } from "@/hooks/useSubscription"; import { OpensoxProBadge } from "../sheet/OpensoxProBadge"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; +import { useAnalytics } from "@/hooks/useAnalytics"; type RouteConfig = { path: string; @@ -82,6 +83,7 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) { const pathname = usePathname(); const { isPaidUser } = useSubscription(); const [proSectionExpanded, setProSectionExpanded] = useState(true); + const { trackLinkClick, trackButtonClick } = useAnalytics(); // auto-expand pro section if user is on a premium route useEffect(() => { @@ -96,6 +98,13 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) { }, [pathname, isPaidUser]); const reqFeatureHandler = () => { + // Track feature request click + trackLinkClick( + "https://github.com/apsinghdev/opensox/issues", + "Request a feature", + "sidebar", + true + ); window.open("https://github.com/apsinghdev/opensox/issues", "_blank"); }; @@ -103,6 +112,8 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) { if (isPaidUser) { setProSectionExpanded(!proSectionExpanded); } else { + // Track upgrade button click for free users + trackButtonClick("Opensox Pro", "sidebar"); router.push("/pricing"); } }; @@ -166,7 +177,14 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) { const isActive = pathname === route.path || pathname.startsWith(`${route.path}/`); return ( - + { + // Track navigation link click + trackLinkClick(route.path, route.label, "sidebar", false); + }} + >
    + { + // Track premium navigation link click + trackLinkClick( + route.path, + route.label, + "sidebar", + false + ); + }} + >
    (
    router.push("/pricing")} + onClick={() => { + // Track locked premium feature click + trackButtonClick(`${route.label} (Locked)`, "sidebar"); + router.push("/pricing"); + }} className="w-full h-[44px] flex items-center rounded-md cursor-pointer transition-colors px-2 gap-3 opacity-50 hover:opacity-75 group" role="button" tabIndex={0} @@ -357,6 +391,7 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) { onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); + trackButtonClick(`${route.label} (Locked)`, "sidebar"); router.push("/pricing"); } }} @@ -427,6 +462,7 @@ function ProfileMenu({ isCollapsed }: { isCollapsed: boolean }) { const [open, setOpen] = useState(false); const { data: session } = useSession(); const router = useRouter(); + const { trackButtonClick, trackLinkClick } = useAnalytics(); const isLoggedIn = !!session; const fullName = session?.user?.name || "User"; @@ -506,6 +542,13 @@ function ProfileMenu({ isCollapsed }: { isCollapsed: boolean }) { {isLoggedIn && ( - {error &&

    {error}

    }
    )} + {error && ( +

    {error}

    + )}
    ); diff --git a/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx b/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx new file mode 100644 index 00000000..b891b846 --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useSubscription } from "@/hooks/useSubscription"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { proSessions, type ProSession } from "@/data/pro-sessions"; +import { + Play, + CheckCircle2, + ExternalLink, + ArrowLeft, +} from "lucide-react"; +import Link from "next/link"; + +function SessionCard({ + session, + index, +}: { + session: ProSession; + index: number; +}) { + const [isHovered, setIsHovered] = useState(false); + + const handleClick = () => { + window.open(session.youtubeUrl, "_blank", "noopener,noreferrer"); + }; + + return ( +
    setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + className="group relative bg-dash-surface border border-dash-border rounded-xl p-5 cursor-pointer + transition-all duration-300 ease-out + hover:border-brand-purple/50 hover:bg-dash-hover hover:shadow-lg hover:shadow-brand-purple/5 + hover:-translate-y-1 active:scale-[0.98]" + style={{ + animationDelay: `${index * 50}ms`, + }} + > + {/* Session number badge */} +
    +
    +
    + + {String(session.id).padStart(2, "0")} + +
    +

    + {session.title} +

    +
    +
    + +
    +
    + + {/* Topics covered */} +
    +

    + Topics Covered +

    +
      + {session.topicsCovered.map((topic, topicIndex) => ( +
    • + + + {topic} + +
    • + ))} +
    +
    + + {/* Watch now indicator */} +
    + + Watch on YouTube +
    + + {/* Hover glow effect */} +
    +
    + ); +} + +export default function ProSessionsPage() { + const { isPaidUser, isLoading } = useSubscription(); + const router = useRouter(); + + useEffect(() => { + if (!isLoading && !isPaidUser) { + router.push("/pricing"); + } + }, [isPaidUser, isLoading, router]); + + if (isLoading) { + return ( +
    +
    +
    +

    Loading sessions...

    +
    +
    + ); + } + + if (!isPaidUser) { + return null; + } + + return ( +
    +
    + {/* Header */} +
    + {/* Back link */} + + + Back to Pro Dashboard + + +
    + +

    + Opensox Pro Sessions +

    +
    +

    + Recordings of Opensox Pro session meetings covering advanced open source strategies, + real-world examples, and insider tips to accelerate your journey. +

    +
    + + {/* Sessions Grid */} +
    + {proSessions.map((session, index) => ( + + ))} +
    + + {/* Footer note */} +
    +

    + More sessions coming soon • Stay tuned for updates +

    +
    +
    +
    + ); +} diff --git a/apps/web/src/data/pro-sessions.ts b/apps/web/src/data/pro-sessions.ts new file mode 100644 index 00000000..dacd0422 --- /dev/null +++ b/apps/web/src/data/pro-sessions.ts @@ -0,0 +1,112 @@ +/** + * Pro Sessions Data + * + * Contains all the Opensox Pro Session YouTube videos with their topics + */ + +export interface ProSession { + id: number; + title: string; + youtubeUrl: string; + topicsCovered: string[]; + duration?: string; +} + +export const proSessions: ProSession[] = [ + { + id: 1, + title: "Pro Session 01", + youtubeUrl: "https://www.youtube.com/watch?v=nZOLgP2P8aQ", + topicsCovered: [ + "Introduction to Open Source Contributions", + "Setting up your development environment", + "Understanding Git workflow basics", + ], + }, + { + id: 2, + title: "Pro Session 02", + youtubeUrl: "https://www.youtube.com/watch?v=fp6fiTce-fI", + topicsCovered: [ + "Finding your first issue", + "Reading project documentation effectively", + "Communicating with maintainers", + ], + }, + { + id: 3, + title: "Pro Session 03", + youtubeUrl: "https://www.youtube.com/watch?v=nZOLgP2P8aQ", + topicsCovered: [ + "Writing clean pull requests", + "Code review best practices", + "Handling feedback gracefully", + ], + }, + { + id: 4, + title: "Pro Session 04", + youtubeUrl: "https://www.youtube.com/watch?v=fp6fiTce-fI", + topicsCovered: [ + "Building your Open Source portfolio", + "Showcasing contributions on GitHub", + "Networking in the OSS community", + ], + }, + { + id: 5, + title: "Pro Session 05", + youtubeUrl: "https://www.youtube.com/watch?v=nZOLgP2P8aQ", + topicsCovered: [ + "Advanced Git techniques", + "Rebasing and resolving conflicts", + "Cherry-picking commits", + ], + }, + { + id: 6, + title: "Pro Session 06", + youtubeUrl: "https://www.youtube.com/watch?v=fp6fiTce-fI", + topicsCovered: [ + "Understanding CI/CD pipelines", + "Writing effective tests", + "Debugging failed builds", + ], + }, + { + id: 7, + title: "Pro Session 07", + youtubeUrl: "https://www.youtube.com/watch?v=nZOLgP2P8aQ", + topicsCovered: [ + "Contributing to documentation", + "Writing technical content", + "Documentation as code", + ], + }, + { + id: 8, + title: "Pro Session 08", + youtubeUrl: "https://www.youtube.com/watch?v=fp6fiTce-fI", + topicsCovered: [ + "Preparing for GSoC", + "Writing winning proposals", + "Building relationships with mentors", + ], + }, + { + id: 9, + title: "Pro Session 09", + youtubeUrl: "https://www.youtube.com/watch?v=nZOLgP2P8aQ", + topicsCovered: [ + "Landing your first OSS internship", + "Resume tips for developers", + "Leveraging OSS for career growth", + "Landing your first OSS internship", + "Resume tips for developers", + "Leveraging OSS for career growth", + "Landing your first OSS internship", + "Resume tips for developers", + "Leveraging OSS for career growth", + ], + }, +]; From b82770355851db2de591fffa97fe0be515ed35db Mon Sep 17 00:00:00 2001 From: Aman Raj <113578582+huamanraj@users.noreply.github.com> Date: Sat, 20 Dec 2025 18:41:47 +0530 Subject: [PATCH 12/17] fix: made divs keyboard accessible --- .../(main)/dashboard/pro/sessions/page.tsx | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx b/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx index b891b846..945394d1 100644 --- a/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx +++ b/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx @@ -1,16 +1,13 @@ "use client"; -import { useSubscription } from "@/hooks/useSubscription"; -import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import { proSessions, type ProSession } from "@/data/pro-sessions"; -import { - Play, - CheckCircle2, - ExternalLink, - ArrowLeft, -} from "lucide-react"; + +import { ArrowLeft, CheckCircle2, ExternalLink, Play } from "lucide-react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; + +import { useSubscription } from "@/hooks/useSubscription"; +import { proSessions, type ProSession } from "@/data/pro-sessions"; function SessionCard({ session, @@ -27,13 +24,23 @@ function SessionCard({ return (
    { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleClick(); + } + }} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + aria-label={`Watch session: ${session.title}`} className="group relative bg-dash-surface border border-dash-border rounded-xl p-5 cursor-pointer transition-all duration-300 ease-out hover:border-brand-purple/50 hover:bg-dash-hover hover:shadow-lg hover:shadow-brand-purple/5 - hover:-translate-y-1 active:scale-[0.98]" + hover:-translate-y-1 active:scale-[0.98] + focus-visible:ring-2 focus-visible:ring-brand-purple/50 focus-visible:outline-none" style={{ animationDelay: `${index * 50}ms`, }} @@ -155,14 +162,14 @@ export default function ProSessionsPage() {
    -

    Opensox Pro Sessions

    - Recordings of Opensox Pro session meetings covering advanced open source strategies, - real-world examples, and insider tips to accelerate your journey. + Recordings of Opensox Pro session meetings covering advanced open + source strategies, real-world examples, and insider tips to + accelerate your journey.

    From e5c4ab81ea26bbbea20c3957467425c012d03452 Mon Sep 17 00:00:00 2001 From: Aman Raj <113578582+huamanraj@users.noreply.github.com> Date: Wed, 24 Dec 2025 15:55:35 +0530 Subject: [PATCH 13/17] fix: ts errors --- .../src/app/(main)/dashboard/pro/sessions/page.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx b/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx index 945394d1..337fa857 100644 --- a/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx +++ b/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx @@ -9,13 +9,13 @@ import { useRouter } from "next/navigation"; import { useSubscription } from "@/hooks/useSubscription"; import { proSessions, type ProSession } from "@/data/pro-sessions"; -function SessionCard({ +const SessionCard = ({ session, index, }: { session: ProSession; index: number; -}) { +}): JSX.Element | null => { const [isHovered, setIsHovered] = useState(false); const handleClick = () => { @@ -114,14 +114,14 @@ function SessionCard({ transition-opacity duration-500 pointer-events-none" style={{ background: - "radial-gradient(ellipse at center, rgba(85, 25, 247, 0.05) 0%, transparent 70%)", + "radial-gradient(ellipse at center, rgb(85 25 247 / 0.05) 0%, transparent 70%)", }} />
    ); -} +}; -export default function ProSessionsPage() { +const ProSessionsPage = (): JSX.Element | null => { const { isPaidUser, isLoading } = useSubscription(); const router = useRouter(); @@ -189,4 +189,6 @@ export default function ProSessionsPage() {
    ); -} +}; + +export default ProSessionsPage; From b40243e53989cc572c7e8e696472ad4af9e32278 Mon Sep 17 00:00:00 2001 From: Aman Raj <113578582+huamanraj@users.noreply.github.com> Date: Sat, 27 Dec 2025 01:23:55 +0530 Subject: [PATCH 14/17] migrated pro session to db from frotend --- apps/api/prisma/schema.prisma | 25 +++ apps/api/src/routers/_app.ts | 2 + apps/api/src/routers/sessions.ts | 11 ++ apps/api/src/services/session.service.ts | 46 ++++++ .../(main)/dashboard/pro/sessions/page.tsx | 144 ++++++++++++++---- 5 files changed, 196 insertions(+), 32 deletions(-) create mode 100644 apps/api/src/routers/sessions.ts create mode 100644 apps/api/src/services/session.service.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 2bf9695f..0827dbae 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -102,3 +102,28 @@ model Plan { updatedAt DateTime @updatedAt subscriptions Subscription[] } + +model WeeklySession { + id String @id @default(cuid()) + title String + description String? + youtubeUrl String + sessionDate DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + topics SessionTopic[] + + @@index([sessionDate]) + @@index([createdAt]) +} + +model SessionTopic { + id String @id @default(cuid()) + sessionId String + timestamp String + topic String + order Int + session WeeklySession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@index([sessionId, order]) +} \ No newline at end of file diff --git a/apps/api/src/routers/_app.ts b/apps/api/src/routers/_app.ts index 782b4361..252e48b3 100644 --- a/apps/api/src/routers/_app.ts +++ b/apps/api/src/routers/_app.ts @@ -4,6 +4,7 @@ import { userRouter } from "./user.js"; import { projectRouter } from "./projects.js"; import { authRouter } from "./auth.js"; import { paymentRouter } from "./payment.js"; +import { sessionsRouter } from "./sessions.js"; import { z } from "zod"; const testRouter = router({ @@ -21,6 +22,7 @@ export const appRouter = router({ project: projectRouter, auth: authRouter, payment: paymentRouter, + sessions: sessionsRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/api/src/routers/sessions.ts b/apps/api/src/routers/sessions.ts new file mode 100644 index 00000000..ab99d658 --- /dev/null +++ b/apps/api/src/routers/sessions.ts @@ -0,0 +1,11 @@ +import { router, protectedProcedure } from "../trpc.js"; +import { sessionService } from "../services/session.service.js"; + +export const sessionsRouter = router({ + // get all sessions for authenticated paid users + getAll: protectedProcedure.query(async ({ ctx }: any) => { + const userId = ctx.user.id; + return await sessionService.getSessions(ctx.db.prisma, userId); + }), +}); + diff --git a/apps/api/src/services/session.service.ts b/apps/api/src/services/session.service.ts new file mode 100644 index 00000000..a2b2d5e9 --- /dev/null +++ b/apps/api/src/services/session.service.ts @@ -0,0 +1,46 @@ +import type { PrismaClient } from "@prisma/client"; +import type { ExtendedPrismaClient } from "../prisma.js"; +import { SUBSCRIPTION_STATUS } from "../constants/subscription.js"; + +export const sessionService = { + /** + * Get all sessions for authenticated paid users + * Sessions are ordered by sessionDate descending (newest first) + */ + async getSessions( + prisma: ExtendedPrismaClient | PrismaClient, + userId: string + ) { + // verify user has active subscription + const subscription = await prisma.subscription.findFirst({ + where: { + userId, + status: SUBSCRIPTION_STATUS.ACTIVE, + endDate: { + gte: new Date(), + }, + }, + }); + + if (!subscription) { + throw new Error("Active subscription required to access sessions"); + } + + // fetch sessions with topics ordered by sessionDate descending + const sessions = await prisma.weeklySession.findMany({ + include: { + topics: { + orderBy: { + order: "asc", + }, + }, + }, + orderBy: { + sessionDate: "desc", + }, + }); + + return sessions; + }, +}; + diff --git a/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx b/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx index 337fa857..c1abaf41 100644 --- a/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx +++ b/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx @@ -5,15 +5,34 @@ import { useEffect, useState } from "react"; import { ArrowLeft, CheckCircle2, ExternalLink, Play } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useSession } from "next-auth/react"; import { useSubscription } from "@/hooks/useSubscription"; -import { proSessions, type ProSession } from "@/data/pro-sessions"; +import { trpc } from "@/lib/trpc"; + +interface SessionTopic { + id: string; + timestamp: string; + topic: string; + order: number; +} + +interface WeeklySession { + id: string; + title: string; + description: string | null; + youtubeUrl: string; + sessionDate: Date; + createdAt: Date; + updatedAt: Date; + topics: SessionTopic[]; +} const SessionCard = ({ session, index, }: { - session: ProSession; + session: WeeklySession; index: number; }): JSX.Element | null => { const [isHovered, setIsHovered] = useState(false); @@ -53,7 +72,7 @@ const SessionCard = ({ group-hover:bg-brand-purple/20 transition-colors duration-300" > - {String(session.id).padStart(2, "0")} + {String(index + 1).padStart(2, "0")}

    @@ -77,27 +96,32 @@ const SessionCard = ({

    {/* Topics covered */} -
    -

    - Topics Covered -

    -
      - {session.topicsCovered.map((topic, topicIndex) => ( -
    • - - - {topic} - -
    • - ))} -
    -
    + {session.topics && session.topics.length > 0 && ( +
    +

    + Topics Covered +

    +
      + {session.topics.map((topic) => ( +
    • + + + {topic.timestamp && ( + [{topic.timestamp}] + )} + {topic.topic} + +
    • + ))} +
    +
    + )} {/* Watch now indicator */}
    { - const { isPaidUser, isLoading } = useSubscription(); + const { isPaidUser, isLoading: subscriptionLoading } = useSubscription(); + const { data: session, status } = useSession(); const router = useRouter(); + // fetch sessions from api + const { + data: sessions, + isLoading: sessionsLoading, + isError: sessionsError, + error: sessionsErrorData, + } = (trpc.sessions as any).getAll.useQuery(undefined, { + enabled: !!session?.user && status === "authenticated" && isPaidUser, + refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, // consider data fresh for 5 minutes + }); + useEffect(() => { - if (!isLoading && !isPaidUser) { + if (!subscriptionLoading && !isPaidUser) { router.push("/pricing"); } - }, [isPaidUser, isLoading, router]); + }, [isPaidUser, subscriptionLoading, router]); + + const isLoading = subscriptionLoading || sessionsLoading; + const hasError = sessionsError; if (isLoading) { return ( @@ -146,6 +186,35 @@ const ProSessionsPage = (): JSX.Element | null => { return null; } + if (hasError) { + return ( +
    +
    +
    + + + Back to Pro Dashboard + +
    +
    +

    + Failed to load sessions. Please try again later. +

    + {sessionsErrorData && ( +

    + {(sessionsErrorData as any)?.message || "Unknown error"} +

    + )} +
    +
    +
    + ); + } + return (
    @@ -174,11 +243,22 @@ const ProSessionsPage = (): JSX.Element | null => {
    {/* Sessions Grid */} -
    - {proSessions.map((session, index) => ( - - ))} -
    + {sessions && sessions.length > 0 ? ( +
    + {sessions.map((session: WeeklySession, index: number) => ( + + ))} +
    + ) : ( +
    +

    + No sessions available yet. +

    +

    + Check back soon for new session recordings. +

    +
    + )} {/* Footer note */}
    From 57b1f2f0974a38f05ff750c506fa7dedabc58eff Mon Sep 17 00:00:00 2001 From: Aman Raj <113578582+huamanraj@users.noreply.github.com> Date: Sat, 27 Dec 2025 01:53:25 +0530 Subject: [PATCH 15/17] type error fix --- apps/api/src/routers/sessions.ts | 2 +- apps/api/src/trpc.ts | 2 +- apps/web/package.json | 6 +++--- apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/api/src/routers/sessions.ts b/apps/api/src/routers/sessions.ts index ab99d658..5bab2f84 100644 --- a/apps/api/src/routers/sessions.ts +++ b/apps/api/src/routers/sessions.ts @@ -3,7 +3,7 @@ import { sessionService } from "../services/session.service.js"; export const sessionsRouter = router({ // get all sessions for authenticated paid users - getAll: protectedProcedure.query(async ({ ctx }: any) => { + getAll: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.user.id; return await sessionService.getSessions(ctx.db.prisma, userId); }), diff --git a/apps/api/src/trpc.ts b/apps/api/src/trpc.ts index d9049676..ff97b6ae 100644 --- a/apps/api/src/trpc.ts +++ b/apps/api/src/trpc.ts @@ -37,4 +37,4 @@ const isAuthed = t.middleware(async ({ ctx, next }) => { export const router = t.router; export const publicProcedure = t.procedure; -export const protectedProcedure:any = t.procedure.use(isAuthed); +export const protectedProcedure = t.procedure.use(isAuthed); diff --git a/apps/web/package.json b/apps/web/package.json index c302d2b6..f7b9194f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,9 +17,9 @@ "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", - "@trpc/client": "^11.6.0", - "@trpc/react-query": "^11.6.0", - "@trpc/server": "^11.5.1", + "@trpc/client": "^11.7.2", + "@trpc/react-query": "^11.7.2", + "@trpc/server": "^11.7.2", "@vercel/analytics": "^1.4.1", "@vercel/speed-insights": "^1.1.0", "class-variance-authority": "^0.7.0", diff --git a/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx b/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx index c1abaf41..a009cc29 100644 --- a/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx +++ b/apps/web/src/app/(main)/dashboard/pro/sessions/page.tsx @@ -156,7 +156,7 @@ const ProSessionsPage = (): JSX.Element | null => { isLoading: sessionsLoading, isError: sessionsError, error: sessionsErrorData, - } = (trpc.sessions as any).getAll.useQuery(undefined, { + } = trpc.sessions.getAll.useQuery(undefined, { enabled: !!session?.user && status === "authenticated" && isPaidUser, refetchOnWindowFocus: false, staleTime: 5 * 60 * 1000, // consider data fresh for 5 minutes @@ -206,7 +206,7 @@ const ProSessionsPage = (): JSX.Element | null => {

    {sessionsErrorData && (

    - {(sessionsErrorData as any)?.message || "Unknown error"} + {sessionsErrorData.message || "Unknown error"}

    )}
    From 27a00ab440f5446dc93f52051313f7e86fdf1055 Mon Sep 17 00:00:00 2001 From: Aman Raj <113578582+huamanraj@users.noreply.github.com> Date: Mon, 29 Dec 2025 21:47:12 +0530 Subject: [PATCH 16/17] refactor: restructure session components and optimize data fetching --- apps/api/src/services/session.service.ts | 15 +- .../pro/sessions/_components/SessionCard.tsx | 43 +++++ .../_components/SessionVideoDialog.tsx | 142 ++++++++++++++++ .../pro/sessions/_components/session-types.ts | 17 ++ .../pro/sessions/_components/youtube.ts | 36 ++++ .../(main)/dashboard/pro/sessions/page.tsx | 157 +++--------------- 6 files changed, 271 insertions(+), 139 deletions(-) create mode 100644 apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionCard.tsx create mode 100644 apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionVideoDialog.tsx create mode 100644 apps/web/src/app/(main)/dashboard/pro/sessions/_components/session-types.ts create mode 100644 apps/web/src/app/(main)/dashboard/pro/sessions/_components/youtube.ts diff --git a/apps/api/src/services/session.service.ts b/apps/api/src/services/session.service.ts index a2b2d5e9..e28637bc 100644 --- a/apps/api/src/services/session.service.ts +++ b/apps/api/src/services/session.service.ts @@ -26,10 +26,21 @@ export const sessionService = { throw new Error("Active subscription required to access sessions"); } - // fetch sessions with topics ordered by sessionDate descending + // fetch only fields needed by the web ui; keep topics ordered const sessions = await prisma.weeklySession.findMany({ - include: { + select: { + id: true, + title: true, + description: true, + youtubeUrl: true, + sessionDate: true, topics: { + select: { + id: true, + timestamp: true, + topic: true, + order: true, + }, orderBy: { order: "asc", }, diff --git a/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionCard.tsx b/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionCard.tsx new file mode 100644 index 00000000..16d2d8e1 --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionCard.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Play } from "lucide-react"; + +import type { WeeklySession } from "./session-types"; + +type SessionCardProps = { + session: WeeklySession; + onPlayAction: (session: WeeklySession) => void; +}; + +export function SessionCard({ + session, + onPlayAction, +}: SessionCardProps): JSX.Element | null { + return ( +
    +
    +
    +

    + {session.title} +

    + {session.description ? ( +

    + {session.description} +

    + ) : null} +
    + + +
    +
    + ); +} + + diff --git a/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionVideoDialog.tsx b/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionVideoDialog.tsx new file mode 100644 index 00000000..d0bcff5a --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/pro/sessions/_components/SessionVideoDialog.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useEffect, useMemo, useRef } from "react"; + +import { CheckCircle2, X } from "lucide-react"; + +import { getYoutubeEmbedUrl } from "./youtube"; +import type { WeeklySession } from "./session-types"; + +type SessionVideoDialogProps = { + isOpen: boolean; + session: WeeklySession | null; + onCloseAction: () => void; +}; + +export function SessionVideoDialog({ + isOpen, + session, + onCloseAction, +}: SessionVideoDialogProps): JSX.Element | null { + const closeButtonRef = useRef(null); + + const embedUrl = useMemo(() => { + if (!session?.youtubeUrl) return null; + return getYoutubeEmbedUrl(session.youtubeUrl); + }, [session?.youtubeUrl]); + + useEffect(() => { + if (!isOpen) return; + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + closeButtonRef.current?.focus(); + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isOpen]); + + if (!isOpen || !session) return null; + + return ( +
    { + if (e.key === "Escape") onCloseAction(); + }} + > + +
    + +
    +
    +
    + {embedUrl ? ( +