feat: add course checkout UI and stripe integration and webhook wip

This commit is contained in:
swve 2024-11-01 20:51:52 +01:00
parent d8913d1a60
commit 1bff401e73
18 changed files with 1086 additions and 131 deletions

View file

@ -0,0 +1,161 @@
import React, { useState } from 'react'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import useSWR from 'swr'
import { getProductsByCourse, getStripeProductCheckoutSession } from '@services/payments/products'
import { RefreshCcw, SquareCheck, ChevronDown, ChevronUp } from 'lucide-react'
import { Badge } from '@components/ui/badge'
import { Button } from '@components/ui/button'
import toast from 'react-hot-toast'
import { useRouter } from 'next/navigation'
import { getUriWithOrg } from '@services/config/config'
interface CoursePaidOptionsProps {
course: {
id: string;
org_id: number;
}
}
function CoursePaidOptions({ course }: CoursePaidOptionsProps) {
const org = useOrg() as any
const session = useLHSession() as any
const [expandedProducts, setExpandedProducts] = useState<{ [key: string]: boolean }>({})
const [isProcessing, setIsProcessing] = useState<{ [key: string]: boolean }>({})
const router = useRouter()
const { data: linkedProducts, error } = useSWR(
() => org && session ? [`/payments/${course.org_id}/courses/${course.id}/products`, session.data?.tokens?.access_token] : null,
([url, token]) => getProductsByCourse(course.org_id, course.id, token)
)
const handleCheckout = async (productId: number) => {
if (!session.data?.user) {
// Redirect to login if user is not authenticated
router.push(`/signup?orgslug=${org.slug}`)
return
}
try {
setIsProcessing(prev => ({ ...prev, [productId]: true }))
const redirect_uri = getUriWithOrg(org.slug, '/courses')
const response = await getStripeProductCheckoutSession(
course.org_id,
productId,
redirect_uri,
session.data?.tokens?.access_token
)
if (response.success) {
router.push(response.data.checkout_url)
} else {
toast.error('Failed to initiate checkout process')
}
} catch (error) {
toast.error('An error occurred while processing your request')
} finally {
setIsProcessing(prev => ({ ...prev, [productId]: false }))
}
}
const toggleProductExpansion = (productId: string) => {
setExpandedProducts(prev => ({
...prev,
[productId]: !prev[productId]
}))
}
if (error) return <div>Failed to load product options</div>
if (!linkedProducts) return <div>Loading...</div>
return (
<div className="space-y-4 p-1">
{linkedProducts.data.map((product: any) => (
<div key={product.id} className="bg-slate-50/30 p-4 rounded-lg nice-shadow flex flex-col">
<div className="flex justify-between items-start mb-2">
<div className="flex flex-col space-y-1 items-start">
<Badge className='w-fit flex items-center space-x-2' variant="outline">
{product.product_type === 'subscription' ? <RefreshCcw size={12} /> : <SquareCheck size={12} />}
<span className='text-sm'>
{product.product_type === 'subscription' ? 'Subscription' : 'One-time payment'}
{product.product_type === 'subscription' && ' (per month)'}
</span>
</Badge>
<h3 className="font-bold text-lg">{product.name}</h3>
</div>
</div>
<div className="flex-grow overflow-hidden">
<div className={`transition-all duration-300 ease-in-out ${expandedProducts[product.id] ? 'max-h-[1000px]' : 'max-h-24'
} overflow-hidden`}>
<p className="text-gray-600">
{product.description}
</p>
{product.benefits && (
<div className="mt-2">
<h4 className="font-semibold text-sm">Benefits:</h4>
<p className="text-sm text-gray-600">
{product.benefits}
</p>
</div>
)}
</div>
</div>
<div className="mt-2">
<button
onClick={() => toggleProductExpansion(product.id)}
className="text-slate-500 hover:text-slate-700 text-sm flex items-center"
>
{expandedProducts[product.id] ? (
<>
<ChevronUp size={16} />
<span>Show less</span>
</>
) : (
<>
<ChevronDown size={16} />
<span>Show more</span>
</>
)}
</button>
</div>
<div className="mt-2 flex items-center justify-between bg-gray-100 rounded-md p-2">
<span className="text-sm text-gray-600">
{product.price_type === 'customer_choice' ? 'Minimum Price:' : 'Price:'}
</span>
<div className="flex flex-col items-end">
<span className="font-semibold text-lg">
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: product.currency
}).format(product.amount)}
{product.product_type === 'subscription' && <span className="text-sm text-gray-500 ml-1">/month</span>}
</span>
{product.price_type === 'customer_choice' && (
<span className="text-sm text-gray-500">Choose your price</span>
)}
</div>
</div>
<Button
className="mt-4 w-full"
variant="default"
onClick={() => handleCheckout(product.id)}
disabled={isProcessing[product.id]}
>
{isProcessing[product.id]
? 'Processing...'
: product.product_type === 'subscription'
? 'Subscribe Now'
: 'Purchase Now'
}
</Button>
</div>
))}
</div>
)
}
export default CoursePaidOptions

View file

@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react'
import UserAvatar from '../../UserAvatar'
import { getUserAvatarMediaDirectory } from '@services/media/media'
import { removeCourse, startCourse } from '@services/courses/activity'
import { revalidateTags } from '@services/utils/ts/requests'
import { useRouter } from 'next/navigation'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useMediaQuery } from 'usehooks-ts'
import { getUriWithOrg } from '@services/config/config'
import { getProductsByCourse } from '@services/payments/products'
import { LogIn, LogOut, ShoppingCart, AlertCircle } from 'lucide-react'
import Modal from '@components/StyledElements/Modal/Modal'
import CourseCTA from './CoursePaidOptions'
import CoursePaidOptions from './CoursePaidOptions'
interface Author {
user_uuid: string
avatar_image: string
first_name: string
last_name: string
username: string
}
interface CourseRun {
status: string
course_id: string
}
interface Course {
id: string
authors: Author[]
trail?: {
runs: CourseRun[]
}
}
interface CourseActionsProps {
courseuuid: string
orgslug: string
course: Course & {
org_id: number
}
}
// Separate component for author display
const AuthorInfo = ({ author, isMobile }: { author: Author, isMobile: boolean }) => (
<div className="flex flex-row md:flex-col mx-auto space-y-0 md:space-y-3 space-x-4 md:space-x-0 px-2 py-2 items-center">
<UserAvatar
border="border-8"
avatar_url={author.avatar_image ? getUserAvatarMediaDirectory(author.user_uuid, author.avatar_image) : ''}
predefined_avatar={author.avatar_image ? undefined : 'empty'}
width={isMobile ? 60 : 100}
/>
<div className="md:-space-y-2">
<div className="text-[12px] text-neutral-400 font-semibold">Author</div>
<div className="text-lg md:text-xl font-bold text-neutral-800">
{(author.first_name && author.last_name) ? (
<div className="flex space-x-2 items-center">
<p>{`${author.first_name} ${author.last_name}`}</p>
<span className="text-xs bg-neutral-100 p-1 px-3 rounded-full text-neutral-400 font-semibold">
@{author.username}
</span>
</div>
) : (
<div className="flex space-x-2 items-center">
<p>@{author.username}</p>
</div>
)}
</div>
</div>
</div>
)
const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => {
const router = useRouter()
const session = useLHSession() as any
const [linkedProducts, setLinkedProducts] = useState<any[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false)
const isStarted = course.trail?.runs?.some(
(run) => run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id
) ?? false
useEffect(() => {
const fetchLinkedProducts = async () => {
try {
const response = await getProductsByCourse(
course.org_id,
course.id,
session.data?.tokens?.access_token
)
setLinkedProducts(response.data || [])
} catch (error) {
console.error('Failed to fetch linked products')
} finally {
setIsLoading(false)
}
}
fetchLinkedProducts()
}, [course.id, course.org_id, session.data?.tokens?.access_token])
const handleCourseAction = async () => {
if (!session.data?.user) {
router.push(getUriWithOrg(orgslug, '/signup?orgslug=' + orgslug))
return
}
const action = isStarted ? removeCourse : startCourse
await action('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
router.refresh()
}
if (isLoading) {
return <div className="animate-pulse h-20 bg-gray-100 rounded-lg nice-shadow" />
}
if (linkedProducts.length > 0) {
return (
<div className="space-y-4">
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg nice-shadow">
<div className="flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-amber-800" />
<h3 className="text-amber-800 font-semibold">Paid Course</h3>
</div>
<p className="text-amber-700 text-sm">
This course requires purchase to access its content.
</p>
</div>
<Modal
isDialogOpen={isModalOpen}
onOpenChange={setIsModalOpen}
dialogContent={<CoursePaidOptions course={course} />}
dialogTitle="Purchase Course"
dialogDescription="Select a payment option to access this course"
minWidth="sm"
/>
<button
className="w-full bg-neutral-900 text-white py-3 rounded-lg nice-shadow font-semibold hover:bg-neutral-800 transition-colors flex items-center justify-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<ShoppingCart className="w-5 h-5" />
Purchase Course
</button>
</div>
)
}
return (
<button
onClick={handleCourseAction}
className={`w-full py-3 rounded-lg nice-shadow font-semibold transition-colors flex items-center justify-center gap-2 ${
isStarted
? 'bg-red-500 text-white hover:bg-red-600'
: 'bg-neutral-900 text-white hover:bg-neutral-800'
}`}
>
{!session.data?.user ? (
<>
<LogIn className="w-5 h-5" />
Authenticate to start course
</>
) : isStarted ? (
<>
<LogOut className="w-5 h-5" />
Leave Course
</>
) : (
<>
<LogIn className="w-5 h-5" />
Start Course
</>
)}
</button>
)
}
function CoursesActions({ courseuuid, orgslug, course }: CourseActionsProps) {
const router = useRouter()
const session = useLHSession() as any
const isMobile = useMediaQuery('(max-width: 768px)')
return (
<div className=" space-y-3 antialiased flex flex-col p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
<AuthorInfo author={course.authors[0]} isMobile={isMobile} />
<div className='px-3 py-2'>
<Actions courseuuid={courseuuid} orgslug={orgslug} course={course} />
</div>
</div>
)
}
export default CoursesActions

View file

@ -0,0 +1,268 @@
import { PencilLine, Rss, TentTree } from 'lucide-react'
import React, { useEffect } from 'react'
import { motion } from 'framer-motion'
import { useFormik } from 'formik'
import * as Form from '@radix-ui/react-form'
import FormLayout, {
FormField,
FormLabelAndMessage,
Input,
Textarea,
} from '@components/StyledElements/Form/Form'
import { useCourse } from '@components/Contexts/CourseContext'
import useSWR, { mutate } from 'swr'
import { getAPIUrl } from '@services/config/config'
import { swrFetcher } from '@services/utils/ts/requests'
import useAdminStatus from '@components/Hooks/useAdminStatus'
import { useOrg } from '@components/Contexts/OrgContext'
import { createCourseUpdate, deleteCourseUpdate } from '@services/courses/updates'
import toast from 'react-hot-toast'
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useLHSession } from '@components/Contexts/LHSessionContext'
dayjs.extend(relativeTime);
function CourseUpdates() {
const course = useCourse() as any;
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
const { data: updates } = useSWR(`${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`, (url) => swrFetcher(url, access_token))
const [isModelOpen, setIsModelOpen] = React.useState(false)
function handleModelOpen() {
setIsModelOpen(!isModelOpen)
}
// if user clicks outside the model, close the model
React.useLayoutEffect(() => {
function handleClickOutside(event: any) {
if (event.target.closest('.bg-white') || event.target.id === 'delete-update-button') return;
setIsModelOpen(false);
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div style={{ position: 'relative' }} className='bg-white hover:bg-neutral-50 transition-all ease-linear nice-shadow rounded-full z-20 px-5 py-1'>
<div onClick={handleModelOpen} className='flex items-center space-x-2 font-normal hover:cursor-pointer text-gray-600'>
<div><Rss size={16} /> </div>
<div className='flex space-x-2 items-center'>
<span>Updates</span>
{updates && <span className='text-xs px-2 font-bold py-0.5 rounded-full bg-rose-100 text-rose-900'>{updates.length}</span>}
</div>
</div>
{isModelOpen && <motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
type: 'spring',
stiffness: 1300,
damping: 70,
}}
style={{ position: 'absolute', top: '130%', right: 0 }}
>
<UpdatesSection />
</motion.div>}
</div>
)
}
const UpdatesSection = () => {
const [selectedView, setSelectedView] = React.useState('list')
const adminStatus = useAdminStatus() ;
return (
<div className='bg-white/95 backdrop-blur-md nice-shadow rounded-lg w-[700px] overflow-hidden'>
<div className='bg-gray-50/70 flex justify-between outline outline-1 rounded-lg outline-neutral-200/40'>
<div className='py-2 px-4 font-bold text-gray-500 flex space-x-2 items-center'>
<Rss size={16} />
<span>Updates</span>
</div>
{adminStatus.isAdmin && <div
onClick={() => setSelectedView('new')}
className='py-2 px-4 space-x-2 items-center flex cursor-pointer text-xs font-medium hover:bg-gray-200 bg-gray-100 outline outline-1 outline-neutral-200/40'>
<PencilLine size={14} />
<span>New Update</span>
</div>}
</div>
<div className=''>
{selectedView === 'list' && <UpdatesListView />}
{selectedView === 'new' && <NewUpdateForm setSelectedView={setSelectedView} />}
</div>
</div>
)
}
const NewUpdateForm = ({ setSelectedView }: any) => {
const org = useOrg() as any;
const course = useCourse() as any;
const session = useLHSession() as any;
const validate = (values: any) => {
const errors: any = {}
if (!values.title) {
errors.title = 'Title is required'
}
if (!values.content) {
errors.content = 'Content is required'
}
return errors
}
const formik = useFormik({
initialValues: {
title: '',
content: ''
},
validate,
onSubmit: async (values) => {
const body = {
title: values.title,
content: values.content,
course_uuid: course.courseStructure.course_uuid,
org_id: org.id
}
const res = await createCourseUpdate(body, session.data?.tokens?.access_token)
if (res.status === 200) {
toast.success('Update added successfully')
setSelectedView('list')
mutate(`${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`)
}
else {
toast.error('Failed to add update')
}
},
enableReinitialize: true,
})
useEffect(() => {
}
, [course, org])
return (
<div className='bg-white/95 backdrop-blur-md nice-shadow rounded-lg w-[700px] overflow-hidden flex flex-col -space-y-2'>
<div className='flex flex-col -space-y-2 px-4 pt-4'>
<div className='text-gray-500 px-3 py-0.5 rounded-full font-semibold text-xs'>Test Course </div>
<div className='text-black px-3 py-0.5 rounded-full text-lg font-bold'>Add new Course Update</div>
</div>
<div className='px-5 -py-2'>
<FormLayout onSubmit={formik.handleSubmit}>
<FormField name="title">
<FormLabelAndMessage
label="Title"
message={formik.errors.title}
/>
<Form.Control asChild>
<Input
style={{ backgroundColor: 'white' }}
onChange={formik.handleChange}
value={formik.values.title}
type="text"
required
/>
</Form.Control>
</FormField>
<FormField name="content">
<FormLabelAndMessage
label="Content"
message={formik.errors.content}
/>
<Form.Control asChild>
<Textarea
style={{ backgroundColor: 'white', height: '100px' }}
onChange={formik.handleChange}
value={formik.values.content}
required
/>
</Form.Control>
</FormField>
<div className='flex justify-end py-2'>
<button onClick={() => setSelectedView('list')} className='text-gray-500 px-4 py-2 rounded-md text-sm font-bold antialiased'>Cancel</button>
<button className='bg-black text-white px-4 py-2 rounded-md text-sm font-bold antialiased'>Add Update</button>
</div>
</FormLayout>
</div>
</div>
)
}
const UpdatesListView = () => {
const course = useCourse() as any;
const adminStatus = useAdminStatus() ;
const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token;
const { data: updates } = useSWR(`${getAPIUrl()}courses/${course?.courseStructure?.course_uuid}/updates`, (url) => swrFetcher(url, access_token))
return (
<div className='px-5 bg-white overflow-y-auto' style={{ maxHeight: '400px' }}>
{updates && !adminStatus.loading && updates.map((update: any) => (
<div key={update.id} className='py-2 border-b border-neutral-200 antialiased'>
<div className='font-bold text-gray-500 flex space-x-2 items-center justify-between '>
<div className='flex space-x-2 items-center'>
<span> {update.title}</span>
<span
title={"Created at " + dayjs(update.creation_date).format('MMMM D, YYYY')}
className='text-xs font-semibold text-gray-300'>
{dayjs(update.creation_date).fromNow()}
</span>
</div>
{adminStatus.isAdmin && !adminStatus.loading && <DeleteUpdateButton update={update} />}</div>
<div className='text-gray-600'>{update.content}</div>
</div>
))}
{(!updates || updates.length === 0) &&
<div className='text-gray-500 text-center my-10 py-2 flex flex-col space-y-2'>
<TentTree className='mx-auto' size={40} />
<p>No updates yet</p>
</div>
}
</div>
)
}
const DeleteUpdateButton = ({ update }: any) => {
const session = useLHSession() as any;
const course = useCourse() as any;
const org = useOrg() as any;
const handleDelete = async () => {
const res = await deleteCourseUpdate(course.courseStructure.course_uuid, update.courseupdate_uuid, session.data?.tokens?.access_token)
const toast_loading = toast.loading('Deleting update...')
if (res.status === 200) {
toast.dismiss(toast_loading)
toast.success('Update deleted successfully')
mutate(`${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`)
}
else {
toast.error('Failed to delete update')
}
}
return (
<ConfirmationModal
confirmationButtonText="Delete Update"
confirmationMessage="Are you sure you want to delete this update?"
dialogTitle={'Delete Update ?'}
buttonid='delete-update-button'
dialogTrigger={
<div id='delete-update-button' className='text-rose-600 text-xs bg-rose-100 rounded-full px-2 py-0.5 hover:cursor-pointer'>
Delete
</div>
}
functionToExecute={() => {
handleDelete()
}}
status="warning"
></ConfirmationModal>
)
}
export default CourseUpdates