From 045418397c2670ea5c27a33fa6ef735d910fca5a Mon Sep 17 00:00:00 2001 From: austinkelsay Date: Sun, 11 May 2025 12:46:25 -0500 Subject: [PATCH] extract course logic into reusable hooks --- src/config/appConfig.js | 3 +- src/hooks/courses/index.js | 9 ++ src/hooks/courses/useCoursePayment.js | 67 ++++++++++++ src/hooks/courses/useCourseTabs.js | 141 ++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/hooks/courses/index.js create mode 100644 src/hooks/courses/useCoursePayment.js create mode 100644 src/hooks/courses/useCourseTabs.js diff --git a/src/config/appConfig.js b/src/config/appConfig.js index a8325ba..4363377 100644 --- a/src/config/appConfig.js +++ b/src/config/appConfig.js @@ -11,7 +11,8 @@ const appConfig = { ], authorPubkeys: [ 'f33c8a9617cb15f705fc70cd461cfd6eaf22f9e24c33eabad981648e5ec6f741', - 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345' + 'c67cd3e1a83daa56cff16f635db2fdb9ed9619300298d4701a58e68e84098345', + '6260f29fa75c91aaa292f082e5e87b438d2ab4fdf96af398567b01802ee2fcd4' ], customLightningAddresses: [ { diff --git a/src/hooks/courses/index.js b/src/hooks/courses/index.js new file mode 100644 index 0000000..9f3ab6c --- /dev/null +++ b/src/hooks/courses/index.js @@ -0,0 +1,9 @@ +import useCourseDecryption from '../encryption/useCourseDecryption'; +import useCourseTabs from './useCourseTabs'; +import useCoursePayment from './useCoursePayment'; + +export { + useCourseDecryption, + useCourseTabs, + useCoursePayment +}; \ No newline at end of file diff --git a/src/hooks/courses/useCoursePayment.js b/src/hooks/courses/useCoursePayment.js new file mode 100644 index 0000000..4743cf1 --- /dev/null +++ b/src/hooks/courses/useCoursePayment.js @@ -0,0 +1,67 @@ +import { useCallback, useMemo } from 'react'; +import { useToast } from '../useToast'; +import { useSession } from 'next-auth/react'; + +/** + * Hook to handle course payment processing and authorization + * @param {Object} course - The course object + * @returns {Object} Payment handling utilities and authorization state + */ +const useCoursePayment = (course) => { + const { data: session, update } = useSession(); + const { showToast } = useToast(); + + // Determine if course requires payment + const isPaidCourse = useMemo(() => { + return course?.price && course.price > 0; + }, [course]); + + // Check if user is authorized to access the course + const isAuthorized = useMemo(() => { + if (!session?.user || !course) return !isPaidCourse; // Free courses are always authorized + + return ( + // User is subscribed + session.user.role?.subscribed || + // User is the creator of the course + session.user.pubkey === course.pubkey || + // Course is free + !isPaidCourse || + // User has purchased this specific course + session.user.purchased?.some(purchase => purchase.courseId === course.d) + ); + }, [session, course, isPaidCourse]); + + // Handler for successful payment + const handlePaymentSuccess = useCallback(async (response) => { + if (response && response?.preimage) { + // Update session to reflect purchase + const updated = await update(); + showToast('success', 'Payment Success', 'You have successfully purchased this course'); + return true; + } else { + showToast('error', 'Error', 'Failed to purchase course. Please try again.'); + return false; + } + }, [update, showToast]); + + // Handler for payment errors + const handlePaymentError = useCallback((error) => { + showToast( + 'error', + 'Payment Error', + `Failed to purchase course. Please try again. Error: ${error}` + ); + return false; + }, [showToast]); + + return { + isPaidCourse, + isAuthorized, + handlePaymentSuccess, + handlePaymentError, + session + }; +}; + +export default useCoursePayment; \ No newline at end of file diff --git a/src/hooks/courses/useCourseTabs.js b/src/hooks/courses/useCourseTabs.js new file mode 100644 index 0000000..0714aad --- /dev/null +++ b/src/hooks/courses/useCourseTabs.js @@ -0,0 +1,141 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useRouter } from 'next/router'; +import useWindowWidth from '../useWindowWidth'; + +/** + * Hook to manage course tabs, navigation, and sidebar visibility + * @param {Object} options - Configuration options + * @param {Array} options.tabMap - Optional custom tab map to use + * @param {boolean} options.initialSidebarVisible - Initial sidebar visibility state + * @returns {Object} Tab management utilities and state + */ +const useCourseTabs = (options = {}) => { + const router = useRouter(); + const windowWidth = useWindowWidth(); + const isMobileView = windowWidth <= 968; + + // Tab management state + const [activeTab, setActiveTab] = useState('overview'); + const [sidebarVisible, setSidebarVisible] = useState( + options.initialSidebarVisible !== undefined ? options.initialSidebarVisible : !isMobileView + ); + + // Get tab map based on view mode + const tabMap = useMemo(() => { + const baseTabMap = options.tabMap || ['overview', 'content', 'qa']; + if (isMobileView) { + const mobileTabMap = [...baseTabMap]; + // Insert lessons tab before qa in mobile view + if (!mobileTabMap.includes('lessons')) { + mobileTabMap.splice(2, 0, 'lessons'); + } + return mobileTabMap; + } + return baseTabMap; + }, [isMobileView, options.tabMap]); + + // Update tabs and sidebar based on router query + useEffect(() => { + if (router.isReady) { + const { active } = router.query; + if (active !== undefined) { + // If we have an active lesson, switch to content tab + setActiveTab('content'); + } else { + // Default to overview tab when no active parameter + setActiveTab('overview'); + } + + // Auto-open sidebar on desktop, close on mobile + setSidebarVisible(!isMobileView); + } + }, [router.isReady, router.query, isMobileView]); + + // Get active tab index + const getActiveTabIndex = useCallback(() => { + return tabMap.indexOf(activeTab); + }, [activeTab, tabMap]); + + // Toggle between tabs + const toggleTab = useCallback((indexOrName) => { + const tabName = typeof indexOrName === 'number' + ? tabMap[indexOrName] + : indexOrName; + + setActiveTab(tabName); + + // Only show/hide sidebar on mobile - desktop keeps sidebar visible + if (isMobileView) { + setSidebarVisible(tabName === 'lessons'); + } + }, [tabMap, isMobileView]); + + // Toggle sidebar visibility + const toggleSidebar = useCallback(() => { + setSidebarVisible(prev => !prev); + }, []); + + // Generate tab items for MenuTab component + const getTabItems = useCallback(() => { + const items = [ + { + label: 'Overview', + icon: 'pi pi-home', + }, + { + label: 'Content', + icon: 'pi pi-book', + } + ]; + + // Add lessons tab only on mobile + if (isMobileView) { + items.push({ + label: 'Lessons', + icon: 'pi pi-list', + }); + } + + items.push({ + label: 'Comments', + icon: 'pi pi-comments', + }); + + return items; + }, [isMobileView]); + + // Setup keyboard navigation for tabs + useEffect(() => { + const handleKeyDown = (e) => { + if (e.key === 'ArrowRight') { + const currentIndex = getActiveTabIndex(); + const nextIndex = (currentIndex + 1) % tabMap.length; + toggleTab(nextIndex); + } else if (e.key === 'ArrowLeft') { + const currentIndex = getActiveTabIndex(); + const prevIndex = (currentIndex - 1 + tabMap.length) % tabMap.length; + toggleTab(prevIndex); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [getActiveTabIndex, tabMap, toggleTab]); + + return { + activeTab, + setActiveTab, + sidebarVisible, + setSidebarVisible, + isMobileView, + toggleTab, + toggleSidebar, + getActiveTabIndex, + getTabItems, + tabMap + }; +}; + +export default useCourseTabs; \ No newline at end of file