mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: add user owned courses page
This commit is contained in:
parent
9f1d8c58d1
commit
0e97580747
7 changed files with 188 additions and 15 deletions
|
|
@ -17,6 +17,7 @@ from src.services.payments.payments_courses import (
|
||||||
unlink_course_from_product,
|
unlink_course_from_product,
|
||||||
get_courses_by_product,
|
get_courses_by_product,
|
||||||
)
|
)
|
||||||
|
from src.services.payments.payments_users import get_owned_courses
|
||||||
from src.services.payments.payments_webhook import handle_stripe_webhook
|
from src.services.payments.payments_webhook import handle_stripe_webhook
|
||||||
from src.services.payments.stripe import create_checkout_session
|
from src.services.payments.stripe import create_checkout_session
|
||||||
from src.services.payments.payments_access import check_course_paid_access
|
from src.services.payments.payments_access import check_course_paid_access
|
||||||
|
|
@ -218,3 +219,12 @@ async def api_get_customers(
|
||||||
Get list of customers and their subscriptions for an organization
|
Get list of customers and their subscriptions for an organization
|
||||||
"""
|
"""
|
||||||
return await get_customers(request, org_id, current_user, db_session)
|
return await get_customers(request, org_id, current_user, db_session)
|
||||||
|
|
||||||
|
@router.get("/{org_id}/courses/owned")
|
||||||
|
async def api_get_owned_courses(
|
||||||
|
request: Request,
|
||||||
|
org_id: int,
|
||||||
|
current_user: PublicUser = Depends(get_current_user),
|
||||||
|
db_session: Session = Depends(get_db_session),
|
||||||
|
):
|
||||||
|
return await get_owned_courses(request, current_user, db_session)
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from src.db.organizations import Organization
|
from src.db.organizations import Organization
|
||||||
from src.db.users import PublicUser, AnonymousUser, User
|
from src.db.users import PublicUser, AnonymousUser
|
||||||
from src.db.payments.payments_users import PaymentsUser
|
from src.db.payments.payments_users import PaymentsUser
|
||||||
from src.db.payments.payments_products import PaymentsProduct
|
|
||||||
from src.services.orgs.orgs import rbac_check
|
from src.services.orgs.orgs import rbac_check
|
||||||
from src.services.payments.payments_products import get_payments_product
|
from src.services.payments.payments_products import get_payments_product
|
||||||
from src.services.users.users import read_user_by_id
|
from src.services.users.users import read_user_by_id
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from src.db.courses.courses import Course, CourseRead
|
||||||
|
from src.db.payments.payments_courses import PaymentsCourse
|
||||||
from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData
|
from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData
|
||||||
from src.db.payments.payments_products import PaymentsProduct
|
from src.db.payments.payments_products import PaymentsProduct
|
||||||
from src.db.users import InternalUser, PublicUser, AnonymousUser
|
from src.db.resource_authors import ResourceAuthor
|
||||||
|
from src.db.users import InternalUser, PublicUser, AnonymousUser, User, UserRead
|
||||||
from src.db.organizations import Organization
|
from src.db.organizations import Organization
|
||||||
from src.services.orgs.orgs import rbac_check
|
from src.services.orgs.orgs import rbac_check
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
@ -185,3 +188,59 @@ async def delete_payment_user(
|
||||||
# Delete payment user
|
# Delete payment user
|
||||||
db_session.delete(payment_user)
|
db_session.delete(payment_user)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_owned_courses(
|
||||||
|
request: Request,
|
||||||
|
current_user: PublicUser | AnonymousUser,
|
||||||
|
db_session: Session,
|
||||||
|
) -> list[CourseRead]:
|
||||||
|
# Anonymous users don't own any courses
|
||||||
|
if isinstance(current_user, AnonymousUser):
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get all active/completed payment users for the current user
|
||||||
|
statement = select(PaymentsUser).where(
|
||||||
|
PaymentsUser.user_id == current_user.id,
|
||||||
|
PaymentsUser.status.in_([PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED]) # type: ignore
|
||||||
|
)
|
||||||
|
payment_users = db_session.exec(statement).all()
|
||||||
|
|
||||||
|
# Get all product IDs from payment users
|
||||||
|
product_ids = [pu.payment_product_id for pu in payment_users]
|
||||||
|
|
||||||
|
# Get all courses linked to these products
|
||||||
|
courses = []
|
||||||
|
for product_id in product_ids:
|
||||||
|
# Get courses linked to this product through PaymentsCourse
|
||||||
|
statement = (
|
||||||
|
select(Course)
|
||||||
|
.join(PaymentsCourse, Course.id == PaymentsCourse.course_id) # type: ignore
|
||||||
|
.where(PaymentsCourse.payment_product_id == product_id)
|
||||||
|
)
|
||||||
|
product_courses = db_session.exec(statement).all()
|
||||||
|
courses.extend(product_courses)
|
||||||
|
|
||||||
|
# Remove duplicates by converting to set and back to list
|
||||||
|
unique_courses = list({course.id: course for course in courses}.values())
|
||||||
|
|
||||||
|
# Get authors for each course and convert to CourseRead
|
||||||
|
course_reads = []
|
||||||
|
for course in unique_courses:
|
||||||
|
# Get course authors
|
||||||
|
authors_statement = (
|
||||||
|
select(User)
|
||||||
|
.join(ResourceAuthor)
|
||||||
|
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
||||||
|
)
|
||||||
|
authors = db_session.exec(authors_statement).all()
|
||||||
|
|
||||||
|
# Convert authors to UserRead
|
||||||
|
author_reads = [UserRead.model_validate(author) for author in authors]
|
||||||
|
|
||||||
|
# Create CourseRead object
|
||||||
|
course_read = CourseRead(**course.model_dump(), authors=author_reads)
|
||||||
|
course_reads.append(course_read)
|
||||||
|
|
||||||
|
return course_reads
|
||||||
|
|
||||||
|
|
|
||||||
59
apps/web/app/orgs/[orgslug]/dash/user-account/owned/page.tsx
Normal file
59
apps/web/app/orgs/[orgslug]/dash/user-account/owned/page.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
|
import useSWR from 'swr'
|
||||||
|
import { getOwnedCourses } from '@services/payments/payments'
|
||||||
|
import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail'
|
||||||
|
import PageLoading from '@components/Objects/Loaders/PageLoading'
|
||||||
|
import { BookOpen } from 'lucide-react'
|
||||||
|
|
||||||
|
function OwnedCoursesPage() {
|
||||||
|
const org = useOrg() as any
|
||||||
|
const session = useLHSession() as any
|
||||||
|
const access_token = session?.data?.tokens?.access_token
|
||||||
|
|
||||||
|
const { data: ownedCourses, error, isLoading } = useSWR(
|
||||||
|
org ? [`/payments/${org.id}/courses/owned`, access_token] : null,
|
||||||
|
([url, token]) => getOwnedCourses(org.id, token)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) return <PageLoading />
|
||||||
|
if (error) return <div>Error loading owned courses</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full bg-[#f8f8f8] pl-10 pr-10 pt-5 ">
|
||||||
|
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-6">
|
||||||
|
<h1 className="font-bold text-xl text-gray-800">My Courses</h1>
|
||||||
|
<h2 className="text-gray-500 text-md">Courses you have purchased or subscribed to</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full">
|
||||||
|
{ownedCourses?.map((course: any) => (
|
||||||
|
<div key={course.course_uuid} className="p-3">
|
||||||
|
<CourseThumbnail course={course} orgslug={org.slug} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(!ownedCourses || ownedCourses.length === 0) && (
|
||||||
|
<div className="col-span-full flex justify-center items-center py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<BookOpen className="w-12 h-12 mx-auto text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-600 mb-2">
|
||||||
|
No purchased courses
|
||||||
|
</h2>
|
||||||
|
<p className="text-md text-gray-400">
|
||||||
|
You haven't purchased any courses yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OwnedCoursesPage
|
||||||
|
|
@ -3,7 +3,7 @@ import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
import { signOut } from 'next-auth/react'
|
import { signOut } from 'next-auth/react'
|
||||||
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
|
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
|
||||||
import LearnHouseDashboardLogo from '@public/dashLogo.png'
|
import LearnHouseDashboardLogo from '@public/dashLogo.png'
|
||||||
import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react'
|
import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, Package2, School, Settings, Users, Vault } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
|
|
@ -147,23 +147,41 @@ function DashLeftMenu() {
|
||||||
<UserAvatar border="border-4" width={35} />
|
<UserAvatar border="border-4" width={35} />
|
||||||
</div>
|
</div>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
<div className="flex items-center flex-col space-y-1">
|
<div className="flex items-center flex-col space-y-3">
|
||||||
<ToolTip
|
<div className="flex flex-col space-y-1 py-1">
|
||||||
content={session.data.user.username + "'s Settings"}
|
<ToolTip
|
||||||
|
content={session.data.user.username + "'s Owned Courses"}
|
||||||
|
slateBlack
|
||||||
|
sideOffset={8}
|
||||||
|
side="right"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={'/dash/user-account/owned'}
|
||||||
|
className="py-1"
|
||||||
|
>
|
||||||
|
<Package2
|
||||||
|
className="mx-auto text-neutral-400 cursor-pointer"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</ToolTip>
|
||||||
|
<ToolTip
|
||||||
|
content={session.data.user.username + "'s Settings"}
|
||||||
slateBlack
|
slateBlack
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
side="right"
|
side="right"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={'/dash/user-account/settings/general'}
|
href={'/dash/user-account/settings/general'}
|
||||||
className="py-3"
|
className="py-1"
|
||||||
>
|
>
|
||||||
<Settings
|
<Settings
|
||||||
className="mx-auto text-neutral-400 cursor-pointer"
|
className="mx-auto text-neutral-400 cursor-pointer"
|
||||||
size={18}
|
size={18}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</ToolTip>
|
</ToolTip>
|
||||||
|
</div>
|
||||||
<ToolTip
|
<ToolTip
|
||||||
content={'Logout'}
|
content={'Logout'}
|
||||||
slateBlack
|
slateBlack
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,17 @@
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Settings } from 'lucide-react'
|
import { Package2, Settings } from 'lucide-react'
|
||||||
import UserAvatar from '@components/Objects/UserAvatar'
|
import UserAvatar from '@components/Objects/UserAvatar'
|
||||||
import useAdminStatus from '@components/Hooks/useAdminStatus'
|
import useAdminStatus from '@components/Hooks/useAdminStatus'
|
||||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||||
import { useOrg } from '@components/Contexts/OrgContext'
|
import { useOrg } from '@components/Contexts/OrgContext'
|
||||||
import { getUriWithoutOrg } from '@services/config/config'
|
import { getUriWithoutOrg } from '@services/config/config'
|
||||||
|
import Tooltip from '@components/StyledElements/Tooltip/Tooltip'
|
||||||
|
|
||||||
export const HeaderProfileBox = () => {
|
export const HeaderProfileBox = () => {
|
||||||
const session = useLHSession() as any
|
const session = useLHSession() as any
|
||||||
const isUserAdmin = useAdminStatus()
|
const isUserAdmin = useAdminStatus()
|
||||||
const org = useOrg() as any
|
const org = useOrg() as any
|
||||||
|
|
||||||
useEffect(() => { }
|
useEffect(() => { }
|
||||||
|
|
@ -39,12 +40,30 @@ export const HeaderProfileBox = () => {
|
||||||
<p className='text-sm capitalize'>{session.data.user.username}</p>
|
<p className='text-sm capitalize'>{session.data.user.username}</p>
|
||||||
{isUserAdmin.isAdmin && <div className="text-[10px] bg-rose-300 px-2 font-bold rounded-md shadow-inner py-1">ADMIN</div>}
|
{isUserAdmin.isAdmin && <div className="text-[10px] bg-rose-300 px-2 font-bold rounded-md shadow-inner py-1">ADMIN</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Tooltip
|
||||||
|
content={"Your Owned Courses"}
|
||||||
|
sideOffset={15}
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<Link className="text-gray-600" href={'/dash/user-account/owned'}>
|
||||||
|
<Package2 size={14} />
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
content={"Your Settings"}
|
||||||
|
sideOffset={15}
|
||||||
|
side="bottom"
|
||||||
|
>
|
||||||
|
<Link className="text-gray-600" href={'/dash'}>
|
||||||
|
<Settings size={14} />
|
||||||
|
</Link>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
<div className="py-4">
|
<div className="py-4">
|
||||||
<UserAvatar border="border-4" rounded="rounded-lg" width={30} />
|
<UserAvatar border="border-4" rounded="rounded-lg" width={30} />
|
||||||
</div>
|
</div>
|
||||||
<Link className="text-gray-600" href={'/dash'}>
|
|
||||||
<Settings size={14} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</AccountArea>
|
</AccountArea>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -53,4 +53,13 @@ export async function getOrgCustomers(orgId: number, access_token: string) {
|
||||||
);
|
);
|
||||||
const res = await errorHandling(result);
|
const res = await errorHandling(result);
|
||||||
return res;
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOwnedCourses(orgId: number, access_token: string) {
|
||||||
|
const result = await fetch(
|
||||||
|
`${getAPIUrl()}payments/${orgId}/courses/owned`,
|
||||||
|
RequestBodyWithAuthHeader('GET', null, null, access_token)
|
||||||
|
);
|
||||||
|
const res = await errorHandling(result);
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue