Skip to content
Open
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
25 changes: 25 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
2 changes: 2 additions & 0 deletions apps/api/src/routers/_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -21,6 +22,7 @@ export const appRouter = router({
project: projectRouter,
auth: authRouter,
payment: paymentRouter,
sessions: sessionsRouter,
});

export type AppRouter = typeof appRouter;
11 changes: 11 additions & 0 deletions apps/api/src/routers/sessions.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
const userId = ctx.user.id;
return await sessionService.getSessions(ctx.db.prisma, userId);
}),
});

90 changes: 90 additions & 0 deletions apps/api/src/services/session.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { Prisma, PrismaClient } from "@prisma/client";
import type { ExtendedPrismaClient } from "../prisma.js";
import { SUBSCRIPTION_STATUS } from "../constants/subscription.js";

export type SessionWithTopics = Prisma.WeeklySessionGetPayload<{
select: {
id: true;
title: true;
description: true;
youtubeUrl: true;
sessionDate: true;
topics: {
select: {
id: true;
timestamp: true;
topic: true;
order: true;
};
};
};
}>;

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
): Promise<SessionWithTopics[]> {
try {
// 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");
}
} catch (error) {
// log error with context before rethrowing
// rethrowing ensures automatic transaction rollback if this is part of a transaction
const timestamp = new Date().toISOString();
const functionName = "getSessions";

console.error(
`[${timestamp}] Error in sessionService.${functionName} - userId: ${userId}, endpoint: ${functionName}`,
error
);

// rethrow to ensure transaction rollback (if in transaction) and proper error propagation
throw error;
}

// fetch only fields needed by the web ui; keep topics ordered
const sessions = await prisma.weeklySession.findMany({
select: {
id: true,
title: true,
description: true,
youtubeUrl: true,
sessionDate: true,
topics: {
select: {
id: true,
timestamp: true,
topic: true,
order: true,
},
orderBy: {
order: "asc",
},
},
},
orderBy: {
sessionDate: "desc",
},
});

return sessions;
},
};

2 changes: 1 addition & 1 deletion apps/api/src/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
6 changes: 3 additions & 3 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 14 additions & 3 deletions apps/web/src/app/(main)/dashboard/pro/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { useSubscription } from "@/hooks/useSubscription";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { Play } from "lucide-react";

export default function ProDashboardPage() {
const { isPaidUser, isLoading } = useSubscription();
Expand Down Expand Up @@ -109,17 +111,26 @@ export default function ProDashboardPage() {
soon you&apos;ll see all the pro perks here. thanks for investin!
</h1>
{isPaidUser && (
<div className="mt-6">
<div className="mt-6 flex flex-col sm:flex-row items-center justify-center gap-3">
<Link
href="/dashboard/pro/sessions"
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-purple hover:bg-brand-purple-light text-text-primary font-medium rounded-lg transition-colors duration-200 text-sm"
>
<Play className="w-4 h-4" />
Pro Sessions
</Link>
<button
onClick={handleJoinSlack}
disabled={isJoining}
className="px-4 py-2 bg-brand-purple hover:bg-brand-purple-light text-text-primary font-medium rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
className="px-4 py-2 bg-dash-surface border border-dash-border hover:border-brand-purple/50 text-text-primary font-medium rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isJoining ? "Joining..." : "Join Slack"}
</button>
{error && <p className="text-error-text text-sm mt-2">{error}</p>}
</div>
)}
{error && (
<p className="text-error-text text-sm mt-2 text-center">{error}</p>
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-dash-surface border border-dash-border rounded-xl p-5">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="text-text-primary font-semibold text-lg truncate">
{session.title}
</h3>
{session.description ? (
<p className="text-text-secondary text-sm mt-1 line-clamp-2">
{session.description}
</p>
) : null}
</div>

<button
type="button"
aria-label={`Play session: ${session.title}`}
className="shrink-0 inline-flex items-center justify-center w-10 h-10 rounded-full bg-brand-purple/10 hover:bg-brand-purple transition-all duration-200 focus-visible:ring-2 focus-visible:ring-brand-purple/50 focus-visible:outline-none"
onClick={() => onPlayAction(session)}
>
<Play className="w-4 h-4 text-brand-purple-light hover:text-text-primary" />
</button>
</div>
</div>
);
}


Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement | null>(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 (
<div
className="fixed inset-0 z-50"
role="dialog"
aria-modal="true"
aria-label={`Session video: ${session.title}`}
onKeyDown={(e) => {
if (e.key === "Escape") onCloseAction();
}}
>
<button
type="button"
aria-label="Close session video"
className="absolute inset-0 bg-black/60"
onClick={onCloseAction}
/>

<div className="relative h-full w-full p-4 sm:p-6 flex items-center justify-center">
<div className="relative w-full max-w-5xl bg-dash-surface border border-dash-border rounded-2xl shadow-xl overflow-hidden">
<div className="flex items-center justify-between gap-3 px-4 sm:px-5 py-3 border-b border-dash-border">
<div className="min-w-0">
<p className="text-text-primary font-semibold truncate">
{session.title}
</p>
{session.description ? (
<p className="text-text-muted text-sm truncate">
{session.description}
</p>
) : null}
</div>

<button
ref={closeButtonRef}
type="button"
className="shrink-0 inline-flex items-center justify-center h-9 w-9 rounded-lg bg-dash-raised hover:bg-dash-hover transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-brand-purple/50 focus-visible:outline-none"
onClick={onCloseAction}
>
<X className="h-4 w-4 text-text-secondary" />
</button>
</div>

<div className="grid grid-cols-1 md:grid-cols-5">
<div className="md:col-span-3 p-4 sm:p-5">
<div className="w-full aspect-video rounded-xl overflow-hidden border border-dash-border bg-dash-base">
{embedUrl ? (
<iframe
key={embedUrl}
src={embedUrl}
title={session.title}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
/>
) : (
<div className="h-full w-full flex items-center justify-center p-6">
<p className="text-text-secondary text-sm text-center">
This session video link is invalid.
</p>
</div>
)}
</div>
</div>

<div className="md:col-span-2 border-t md:border-t-0 md:border-l border-dash-border">
<div className="p-4 sm:p-5">
<p className="text-text-muted text-xs uppercase tracking-wider font-medium">
Topics covered
</p>

{session.topics?.length ? (
<ul className="mt-3 space-y-2 max-h-[40vh] md:max-h-[60vh] overflow-auto pr-1">
{session.topics.map((topic) => (
<li
key={topic.id}
className="flex items-start gap-2.5 text-text-secondary text-sm"
>
<CheckCircle2 className="w-4 h-4 text-brand-purple/70 mt-0.5 flex-shrink-0" />
<div className="min-w-0">
<p className="text-text-secondary break-words">
{topic.topic}
</p>
</div>
</li>
))}
</ul>
) : (
<p className="mt-3 text-text-secondary text-sm">
No topics listed for this session yet.
</p>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
}


Loading