mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #316 from learnhouse/feat/payments
Payments & Subscriptions
This commit is contained in:
commit
60740c4166
176 changed files with 7282 additions and 1882 deletions
|
|
@ -15,7 +15,7 @@ import {
|
|||
useAIChatBot,
|
||||
useAIChatBotDispatch,
|
||||
} from '@components/Contexts/AI/AIChatBotContext'
|
||||
import useGetAIFeatures from '../../../AI/Hooks/useGetAIFeatures'
|
||||
import useGetAIFeatures from '../../../Hooks/useGetAIFeatures'
|
||||
import UserAvatar from '@components/Objects/UserAvatar'
|
||||
|
||||
type AIActivityAskProps = {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import learnhouseAI_icon from 'public/learnhouse_ai_simple.png'
|
|||
import Image from 'next/image'
|
||||
import { BookOpen, FormInput, Languages, MoreVertical } from 'lucide-react'
|
||||
import { BubbleMenu } from '@tiptap/react'
|
||||
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
|
||||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||
import {
|
||||
AIChatBotStateTypes,
|
||||
useAIChatBot,
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
sendActivityAIChatMessage,
|
||||
startActivityAIChatSession,
|
||||
} from '@services/ai/ai'
|
||||
import useGetAIFeatures from '../../../../AI/Hooks/useGetAIFeatures'
|
||||
import useGetAIFeatures from '../../../../Hooks/useGetAIFeatures'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
|
||||
type AICanvaToolkitProps = {
|
||||
|
|
|
|||
14
apps/web/components/Objects/ContentPlaceHolder.tsx
Normal file
14
apps/web/components/Objects/ContentPlaceHolder.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
'use client';
|
||||
import React from 'react'
|
||||
import useAdminStatus from '../Hooks/useAdminStatus'
|
||||
|
||||
|
||||
// Terrible name and terible implementation, need to be refactored asap
|
||||
function ContentPlaceHolderIfUserIsNotAdmin({ text }: { text: string }) {
|
||||
const isUserAdmin = useAdminStatus() as any
|
||||
return (
|
||||
<span>{isUserAdmin ? text : 'No content yet'}</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContentPlaceHolderIfUserIsNotAdmin
|
||||
|
|
@ -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 bg-gray-100/50' 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
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
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/Objects/StyledElements/Modal/Modal'
|
||||
import CoursePaidOptions from './CoursePaidOptions'
|
||||
import { checkPaidAccess } from '@services/payments/payments'
|
||||
|
||||
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 [hasAccess, setHasAccess] = useState<boolean | null>(null)
|
||||
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
const checkAccess = async () => {
|
||||
if (!session.data?.user) return
|
||||
try {
|
||||
const response = await checkPaidAccess(
|
||||
parseInt(course.id),
|
||||
course.org_id,
|
||||
session.data?.tokens?.access_token
|
||||
)
|
||||
setHasAccess(response.has_access)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to check course access')
|
||||
setHasAccess(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (linkedProducts.length > 0) {
|
||||
checkAccess()
|
||||
}
|
||||
}, [course.id, course.org_id, session.data?.tokens?.access_token, linkedProducts])
|
||||
|
||||
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">
|
||||
{hasAccess ? (
|
||||
<>
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg nice-shadow">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
<h3 className="text-green-800 font-semibold">You Own This Course</h3>
|
||||
</div>
|
||||
<p className="text-green-700 text-sm mt-1">
|
||||
You have purchased this course and have full access to all content.
|
||||
</p>
|
||||
</div>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
{isStarted ? (
|
||||
<>
|
||||
<LogOut className="w-5 h-5" />
|
||||
Leave Course
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn className="w-5 h-5" />
|
||||
Start Course
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<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 mt-1">
|
||||
This course requires purchase to access its content.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasAccess && (
|
||||
<>
|
||||
<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
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import React from 'react'
|
||||
import { AlertCircle, ShoppingCart } from 'lucide-react'
|
||||
import CoursePaidOptions from './CoursePaidOptions'
|
||||
|
||||
interface PaidCourseActivityProps {
|
||||
course: any;
|
||||
}
|
||||
|
||||
function PaidCourseActivityDisclaimer({ course }: PaidCourseActivityProps) {
|
||||
return (
|
||||
<div className="space-y-4 max-w-lg mx-auto">
|
||||
<div className="p-4 bg-amber-50 border border-amber-200 rounded-lg ">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-amber-800" />
|
||||
<h3 className="text-amber-800 font-semibold">Paid Content</h3>
|
||||
</div>
|
||||
<p className="text-amber-700 text-sm mt-1">
|
||||
This content requires a course purchase to access.
|
||||
</p>
|
||||
</div>
|
||||
<CoursePaidOptions course={course} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaidCourseActivityDisclaimer
|
||||
|
|
@ -8,7 +8,7 @@ import FormLayout, {
|
|||
FormLabelAndMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import { useCourse } from '@components/Contexts/CourseContext'
|
||||
import useSWR, { mutate } from 'swr'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
|
|
@ -17,7 +17,7 @@ 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 ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
|
|
@ -23,7 +23,7 @@ import {
|
|||
sendActivityAIChatMessage,
|
||||
startActivityAIChatSession,
|
||||
} from '@services/ai/ai'
|
||||
import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures'
|
||||
import useGetAIFeatures from '@components/Hooks/useGetAIFeatures'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
|
||||
type AIEditorToolkitProps = {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import Table from '@tiptap/extension-table'
|
|||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
|
||||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||
import Link from 'next/link'
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ import java from 'highlight.js/lib/languages/java'
|
|||
import { CourseProvider } from '@components/Contexts/CourseContext'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import AIEditorToolkit from './AI/AIEditorToolkit'
|
||||
import useGetAIFeatures from '@components/AI/Hooks/useGetAIFeatures'
|
||||
import useGetAIFeatures from '@components/Hooks/useGetAIFeatures'
|
||||
import Collaboration from '@tiptap/extension-collaboration'
|
||||
import CollaborationCursor from '@tiptap/extension-collaboration-cursor'
|
||||
import ActiveAvatars from './ActiveAvatars'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { default as React, useEffect, useRef, useState } from 'react'
|
|||
import Editor from './Editor'
|
||||
import { updateActivity } from '@services/courses/activities'
|
||||
import { toast } from 'react-hot-toast'
|
||||
import Toast from '@components/StyledElements/Toast/Toast'
|
||||
import Toast from '@components/Objects/StyledElements/Toast/Toast'
|
||||
import { OrgProvider } from '@components/Contexts/OrgContext'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import {
|
|||
Video,
|
||||
} from 'lucide-react'
|
||||
import { SiYoutube } from '@icons-pack/react-simple-icons'
|
||||
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
|
||||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||
|
||||
export const ToolbarButtons = ({ editor, props }: any) => {
|
||||
if (!editor) {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import React from 'react'
|
|||
import Link from 'next/link'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { HeaderProfileBox } from '@components/Security/HeaderProfileBox'
|
||||
import MenuLinks from './MenuLinks'
|
||||
import MenuLinks from './OrgMenuLinks'
|
||||
import { getOrgLogoMediaDirectory } from '@services/media/media'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
|
||||
export const Menu = (props: any) => {
|
||||
export const OrgMenu = (props: any) => {
|
||||
const orgslug = props.orgslug
|
||||
const session = useLHSession() as any;
|
||||
const access_token = session?.data?.tokens?.access_token;
|
||||
|
|
@ -7,7 +7,7 @@ import FormLayout, {
|
|||
FormMessage,
|
||||
Input,
|
||||
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { BarLoader } from 'react-spinners'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import FormLayout, {
|
|||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import React, { useState } from 'react'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import BarLoader from 'react-spinners/BarLoader'
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import FormLayout, {
|
|||
FormMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import React, { useState } from 'react'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import BarLoader from 'react-spinners/BarLoader'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import FormLayout, {
|
|||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import React, { useState } from 'react'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import BarLoader from 'react-spinners/BarLoader'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import FormLayout, {
|
|||
Textarea,
|
||||
FormLabel,
|
||||
ButtonBlack,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import { FormMessage } from '@radix-ui/react-form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import React, { useState } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,189 +1,262 @@
|
|||
'use client'
|
||||
import { Input } from "@components/ui/input"
|
||||
import { Textarea } from "@components/ui/textarea"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@components/ui/select"
|
||||
import FormLayout, {
|
||||
ButtonBlack,
|
||||
Flex,
|
||||
FormField,
|
||||
FormLabel,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
FormLabelAndMessage,
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { FormMessage } from '@radix-ui/react-form'
|
||||
import { createNewCourse } from '@services/courses/courses'
|
||||
import { getOrganizationContextInfoWithoutCredentials } from '@services/organizations/orgs'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { BarLoader } from 'react-spinners'
|
||||
import { revalidateTags } from '@services/utils/ts/requests'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useFormik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import { UploadCloud, Image as ImageIcon } from 'lucide-react'
|
||||
import UnsplashImagePicker from "@components/Dashboard/Pages/Course/EditCourseGeneral/UnsplashImagePicker"
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
name: Yup.string()
|
||||
.required('Course name is required')
|
||||
.max(100, 'Must be 100 characters or less'),
|
||||
description: Yup.string()
|
||||
.max(1000, 'Must be 1000 characters or less'),
|
||||
learnings: Yup.string(),
|
||||
tags: Yup.string(),
|
||||
visibility: Yup.boolean(),
|
||||
thumbnail: Yup.mixed().nullable()
|
||||
})
|
||||
|
||||
function CreateCourseModal({ closeModal, orgslug }: any) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const session = useLHSession() as any;
|
||||
const [name, setName] = React.useState('')
|
||||
const [description, setDescription] = React.useState('')
|
||||
const [learnings, setLearnings] = React.useState('')
|
||||
const [visibility, setVisibility] = React.useState(true)
|
||||
const [tags, setTags] = React.useState('')
|
||||
const [isLoading, setIsLoading] = React.useState(false)
|
||||
const [thumbnail, setThumbnail] = React.useState(null) as any
|
||||
const router = useRouter()
|
||||
|
||||
const session = useLHSession() as any
|
||||
const [orgId, setOrgId] = React.useState(null) as any
|
||||
const [org, setOrg] = React.useState(null) as any
|
||||
const [showUnsplashPicker, setShowUnsplashPicker] = React.useState(false)
|
||||
const [isUploading, setIsUploading] = React.useState(false)
|
||||
|
||||
const formik = useFormik({
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
learnings: '',
|
||||
visibility: true,
|
||||
tags: '',
|
||||
thumbnail: null
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit: async (values, { setSubmitting }) => {
|
||||
const toast_loading = toast.loading('Creating course...')
|
||||
|
||||
try {
|
||||
const res = await createNewCourse(
|
||||
orgId,
|
||||
{
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
tags: values.tags,
|
||||
visibility: values.visibility
|
||||
},
|
||||
values.thumbnail,
|
||||
session.data?.tokens?.access_token
|
||||
)
|
||||
|
||||
if (res.success) {
|
||||
await revalidateTags(['courses'], orgslug)
|
||||
toast.dismiss(toast_loading)
|
||||
toast.success('Course created successfully')
|
||||
|
||||
if (res.data.org_id === orgId) {
|
||||
closeModal()
|
||||
router.refresh()
|
||||
await revalidateTags(['courses'], orgslug)
|
||||
}
|
||||
} else {
|
||||
toast.error(res.data.detail)
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to create course')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const getOrgMetadata = async () => {
|
||||
const org = await getOrganizationContextInfoWithoutCredentials(orgslug, {
|
||||
revalidate: 360,
|
||||
tags: ['organizations'],
|
||||
})
|
||||
|
||||
setOrgId(org.id)
|
||||
}
|
||||
|
||||
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setName(event.target.value)
|
||||
}
|
||||
|
||||
const handleDescriptionChange = (event: React.ChangeEvent<any>) => {
|
||||
setDescription(event.target.value)
|
||||
}
|
||||
|
||||
const handleLearningsChange = (event: React.ChangeEvent<any>) => {
|
||||
setLearnings(event.target.value)
|
||||
}
|
||||
|
||||
const handleVisibilityChange = (event: React.ChangeEvent<any>) => {
|
||||
setVisibility(event.target.value)
|
||||
}
|
||||
|
||||
const handleTagsChange = (event: React.ChangeEvent<any>) => {
|
||||
setTags(event.target.value)
|
||||
}
|
||||
|
||||
const handleThumbnailChange = (event: React.ChangeEvent<any>) => {
|
||||
setThumbnail(event.target.files[0])
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: any) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
let res = await createNewCourse(
|
||||
orgId,
|
||||
{ name, description, tags, visibility },
|
||||
thumbnail,
|
||||
session.data?.tokens?.access_token
|
||||
)
|
||||
const toast_loading = toast.loading('Creating course...')
|
||||
if (res.success) {
|
||||
await revalidateTags(['courses'], orgslug)
|
||||
setIsSubmitting(false)
|
||||
toast.dismiss(toast_loading)
|
||||
toast.success('Course created successfully')
|
||||
|
||||
if (res.data.org_id == orgId) {
|
||||
closeModal()
|
||||
router.refresh()
|
||||
await revalidateTags(['courses'], orgslug)
|
||||
}
|
||||
|
||||
}
|
||||
else {
|
||||
setIsSubmitting(false)
|
||||
toast.error(res.data.detail)
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (orgslug) {
|
||||
getOrgMetadata()
|
||||
}
|
||||
}, [isLoading, orgslug])
|
||||
}, [orgslug])
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file) {
|
||||
formik.setFieldValue('thumbnail', file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnsplashSelect = async (imageUrl: string) => {
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const response = await fetch(imageUrl)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], 'unsplash_image.jpg', { type: 'image/jpeg' })
|
||||
formik.setFieldValue('thumbnail', file)
|
||||
} catch (error) {
|
||||
toast.error('Failed to load image from Unsplash')
|
||||
}
|
||||
setIsUploading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormLayout onSubmit={handleSubmit}>
|
||||
<FormField name="course-name">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Course name</FormLabel>
|
||||
<FormMessage match="valueMissing">
|
||||
Please provide a course name
|
||||
</FormMessage>
|
||||
</Flex>
|
||||
<FormLayout onSubmit={formik.handleSubmit} >
|
||||
<FormField name="name">
|
||||
<FormLabelAndMessage
|
||||
label="Course Name"
|
||||
message={formik.errors.name}
|
||||
/>
|
||||
<Form.Control asChild>
|
||||
<Input onChange={handleNameChange} type="text" required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="course-desc">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Course description</FormLabel>
|
||||
<FormMessage match="valueMissing">
|
||||
Please provide a course description
|
||||
</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Textarea onChange={handleDescriptionChange} required />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="course-thumbnail">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Course thumbnail</FormLabel>
|
||||
<FormMessage match="valueMissing">
|
||||
Please provide a thumbnail for your course
|
||||
</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Input onChange={handleThumbnailChange} type="file" />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="course-tags">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Course Learnings (separated by comma)</FormLabel>
|
||||
<FormMessage match="valueMissing">
|
||||
Please provide learning elements, separated by comma (,)
|
||||
</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<Textarea onChange={handleTagsChange} />
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<FormField name="course-visibility">
|
||||
<Flex css={{ alignItems: 'baseline', justifyContent: 'space-between' }}>
|
||||
<FormLabel>Course Visibility</FormLabel>
|
||||
<FormMessage match="valueMissing">
|
||||
Please choose course visibility
|
||||
</FormMessage>
|
||||
</Flex>
|
||||
<Form.Control asChild>
|
||||
<select
|
||||
onChange={handleVisibilityChange}
|
||||
className="border border-gray-300 rounded-md p-2"
|
||||
<Input
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name}
|
||||
type="text"
|
||||
required
|
||||
>
|
||||
<option value="true">
|
||||
Public (Available to see on the internet){' '}
|
||||
</option>
|
||||
<option value="false">Private (Private to users) </option>
|
||||
</select>
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
||||
<Form.Submit asChild>
|
||||
<ButtonBlack type="submit" css={{ marginTop: 10 }}>
|
||||
{isSubmitting ? (
|
||||
<BarLoader
|
||||
cssOverride={{ borderRadius: 60 }}
|
||||
width={60}
|
||||
color="#ffffff"
|
||||
/>
|
||||
) : (
|
||||
'Create Course'
|
||||
)}
|
||||
</ButtonBlack>
|
||||
</Form.Submit>
|
||||
</Flex>
|
||||
<FormField name="description">
|
||||
<FormLabelAndMessage
|
||||
label="Description"
|
||||
message={formik.errors.description}
|
||||
/>
|
||||
<Form.Control asChild>
|
||||
<Textarea
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.description}
|
||||
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<FormField name="thumbnail">
|
||||
<FormLabelAndMessage
|
||||
label="Course Thumbnail"
|
||||
message={formik.errors.thumbnail}
|
||||
/>
|
||||
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow">
|
||||
<div className="flex flex-col justify-center items-center h-full">
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
{formik.values.thumbnail ? (
|
||||
<img
|
||||
src={URL.createObjectURL(formik.values.thumbnail)}
|
||||
className={`${isUploading ? 'animate-pulse' : ''} shadow w-[200px] h-[100px] rounded-md`}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/empty_thumbnail.png"
|
||||
className="shadow w-[200px] h-[100px] rounded-md bg-gray-200"
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-center items-center space-x-2">
|
||||
<input
|
||||
type="file"
|
||||
id="fileInput"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
|
||||
onClick={() => document.getElementById('fileInput')?.click()}
|
||||
>
|
||||
<UploadCloud size={16} className="mr-2" />
|
||||
<span>Upload Image</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 mt-6 flex"
|
||||
onClick={() => setShowUnsplashPicker(true)}
|
||||
>
|
||||
<ImageIcon size={16} className="mr-2" />
|
||||
<span>Choose from Gallery</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<FormField name="tags">
|
||||
<FormLabelAndMessage
|
||||
label="Course Tags"
|
||||
message={formik.errors.tags}
|
||||
/>
|
||||
<Form.Control asChild>
|
||||
<Textarea
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.tags}
|
||||
placeholder="Enter tags separated by commas"
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<FormField name="visibility">
|
||||
<FormLabelAndMessage
|
||||
label="Course Visibility"
|
||||
message={formik.errors.visibility}
|
||||
/>
|
||||
<Select
|
||||
value={formik.values.visibility.toString()}
|
||||
onValueChange={(value) => formik.setFieldValue('visibility', value === 'true')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select visibility" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Public (Available to see on the internet)</SelectItem>
|
||||
<SelectItem value="false">Private (Private to users)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formik.isSubmitting}
|
||||
className="px-4 py-2 bg-black text-white text-sm font-bold rounded-md"
|
||||
>
|
||||
{formik.isSubmitting ? (
|
||||
<BarLoader
|
||||
cssOverride={{ borderRadius: 60 }}
|
||||
width={60}
|
||||
color="#ffffff"
|
||||
/>
|
||||
) : (
|
||||
'Create Course'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showUnsplashPicker && (
|
||||
<UnsplashImagePicker
|
||||
onSelect={handleUnsplashSelect}
|
||||
onClose={() => setShowUnsplashPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</FormLayout>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import FormLayout, {
|
|||
FormField,
|
||||
FormLabelAndMessage,
|
||||
Input,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import React from 'react'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import FormLayout, {
|
|||
FormField,
|
||||
FormLabelAndMessage,
|
||||
Input,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import React from 'react'
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import FormLayout, {
|
|||
Flex,
|
||||
FormField,
|
||||
FormLabel,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { FormMessage } from '@radix-ui/react-form'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
|
|
|
|||
277
apps/web/components/Objects/Onboarding/Onboarding.tsx
Normal file
277
apps/web/components/Objects/Onboarding/Onboarding.tsx
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import Modal from '@components/Objects/StyledElements/Modal/Modal';
|
||||
import Image, { StaticImageData } from 'next/image';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import OnBoardWelcome from '@public/onboarding/OnBoardWelcome.png';
|
||||
import OnBoardCourses from '@public/onboarding/OnBoardCourses.png';
|
||||
import OnBoardActivities from '@public/onboarding/OnBoardActivities.png';
|
||||
import OnBoardEditor from '@public/onboarding/OnBoardEditor.png';
|
||||
import OnBoardAI from '@public/onboarding/OnBoardAI.png';
|
||||
import OnBoardUGs from '@public/onboarding/OnBoardUGs.png';
|
||||
import OnBoardAccess from '@public/onboarding/OnBoardAccess.png';
|
||||
import OnBoardMore from '@public/onboarding/OnBoardMore.png';
|
||||
import { ArrowRight, Book, Check, Globe, Info, PictureInPicture, Sparkle, Sprout, SquareUser } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getUriWithOrg } from '@services/config/config';
|
||||
import { useOrg } from '@components/Contexts/OrgContext';
|
||||
import useAdminStatus from '@components/Hooks/useAdminStatus';
|
||||
|
||||
interface OnboardingStep {
|
||||
imageSrc: StaticImageData;
|
||||
title: string;
|
||||
description: string;
|
||||
buttons?: {
|
||||
label: string;
|
||||
action: () => void;
|
||||
icon?: React.ReactNode;
|
||||
}[];
|
||||
}
|
||||
|
||||
const Onboarding: React.FC = () => {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isOnboardingComplete, setIsOnboardingComplete] = useState(true);
|
||||
const router = useRouter();
|
||||
const org = useOrg() as any;
|
||||
const isUserAdmin = useAdminStatus() as any;
|
||||
|
||||
const onboardingData: OnboardingStep[] = [
|
||||
{
|
||||
imageSrc: OnBoardWelcome,
|
||||
title: 'Teach the world!',
|
||||
description: 'Welcome to LearnHouse, a LMS engineered for simplicity, ease of use and performance, meet the new way to create, share, and engage with educational content.',
|
||||
},
|
||||
{
|
||||
imageSrc: OnBoardCourses,
|
||||
title: 'Create Courses',
|
||||
description: 'Courses are the main building blocks of LearnHouse, they always contain Chapters and Chapters contain Activities.',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Create New Course',
|
||||
action: () => router.push(getUriWithOrg(org?.slug, '/courses?new=true')),
|
||||
icon: <Book size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: OnBoardActivities,
|
||||
title: 'Activities',
|
||||
description: 'Activities are elements you can add to your Courses via Chapters, they can be : Dynamic Pages, Videos, Documents, Quizz and more soon.',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Learn more about activities',
|
||||
action: () => window.open('https://university.learnhouse.io/course/be89716c-9992-44bb-81df-ef3d76e355ba', '_blank'),
|
||||
icon: <Info size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: OnBoardEditor,
|
||||
title: 'Dynamic pages and The Editor',
|
||||
description: 'Dynamic pages are pages with dynamic content, like Notion pages they can contain various components like Quizzes, Images, Videos, Documents etc',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Learn more about Dynamic Pages and The Editor',
|
||||
action: () => window.open('https://university.learnhouse.io/course/be89716c-9992-44bb-81df-ef3d76e355ba', '_blank'),
|
||||
icon: <Info size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: OnBoardAI,
|
||||
title: 'Artificial Intelligence',
|
||||
description: 'Tools for tought made for teachers and students alike, context aware it can reply based on your courses and the unique content you create on LearnHouse',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Learn more about LearnHouse AI',
|
||||
action: () => window.open('https://docs.learnhouse.app/features/ai/students', '_blank'),
|
||||
icon: <Sparkle size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: OnBoardUGs,
|
||||
title: 'Group students and streamline access ',
|
||||
description: 'With UserGroups you can separate students by Groups and give access to Courses depending on their needs',
|
||||
buttons: [
|
||||
{
|
||||
label: 'Create UserGroups',
|
||||
action: () => router.push(getUriWithOrg(org?.slug, '/dash/users/settings/usergroups')),
|
||||
icon: <SquareUser size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: OnBoardAccess,
|
||||
title: 'Choose whether to make Courses available on the Web or not ',
|
||||
description: 'You can choose to make your Courses discoverable from search engines and accesible to non authenticated users or to only give it to authenticated Users',
|
||||
buttons: [
|
||||
|
||||
],
|
||||
},
|
||||
{
|
||||
imageSrc: OnBoardMore,
|
||||
title: 'To infinity and beyond',
|
||||
description: "To Learn more about LearnHouse, you're welcome to follow our Original courses on the LearnHouse University",
|
||||
buttons: [
|
||||
{
|
||||
label: 'LearnHouse University',
|
||||
action: () => window.open('https://university.learnhouse.io', '_blank'),
|
||||
icon: <Globe size={16} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// Check if onboarding is already completed in local storage
|
||||
const isOnboardingCompleted = localStorage.getItem('isOnboardingCompleted');
|
||||
setIsOnboardingComplete(isOnboardingCompleted === 'true');
|
||||
setIsModalOpen(!isOnboardingCompleted); // Show modal if onboarding is not completed
|
||||
}, []);
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < onboardingData.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
// Mark onboarding as completed in local storage
|
||||
localStorage.setItem('isOnboardingCompleted', 'true');
|
||||
setIsModalOpen(false); // Close modal after completion
|
||||
setIsOnboardingComplete(true); // Show success message
|
||||
console.log('Onboarding completed');
|
||||
}
|
||||
};
|
||||
|
||||
const skipOnboarding = () => {
|
||||
// Mark onboarding as completed in local storage
|
||||
localStorage.setItem('isOnboardingCompleted', 'true');
|
||||
setIsModalOpen(false); // Close modal after skipping
|
||||
console.log('Onboarding skipped');
|
||||
};
|
||||
|
||||
const goToStep = (index: number) => {
|
||||
if (index >= 0 && index < onboardingData.length) {
|
||||
setCurrentStep(index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isUserAdmin.isAdmin && !isUserAdmin.loading && !isOnboardingComplete && <Modal
|
||||
isDialogOpen={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
minHeight="sm"
|
||||
minWidth='md'
|
||||
dialogContent={
|
||||
<OnboardingScreen
|
||||
step={onboardingData[currentStep]}
|
||||
onboardingData={onboardingData}
|
||||
currentStep={currentStep}
|
||||
nextStep={nextStep}
|
||||
skipOnboarding={skipOnboarding}
|
||||
setIsModalOpen={setIsModalOpen}
|
||||
|
||||
goToStep={goToStep}
|
||||
/>
|
||||
}
|
||||
dialogTrigger={
|
||||
|
||||
<div className='fixed pb-10 w-full bottom-0 bg-gradient-to-t from-1% from-gray-950/25 to-transparent'>
|
||||
<div className='bg-gray-950 flex space-x-2 font-bold cursor-pointer hover:bg-gray-900 shadow-md items-center text-gray-200 px-5 py-2 w-fit rounded-full mx-auto'>
|
||||
<Sprout size={20} />
|
||||
<p>Onboarding</p>
|
||||
<div className='h-2 w-2 bg-green-500 animate-pulse rounded-full'></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface OnboardingScreenProps {
|
||||
step: OnboardingStep;
|
||||
currentStep: number;
|
||||
nextStep: () => void;
|
||||
skipOnboarding: () => void;
|
||||
goToStep: (index: number) => void;
|
||||
setIsModalOpen: (value: boolean) => void;
|
||||
onboardingData: OnboardingStep[];
|
||||
}
|
||||
|
||||
const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
|
||||
step,
|
||||
currentStep,
|
||||
nextStep,
|
||||
skipOnboarding,
|
||||
goToStep,
|
||||
onboardingData,
|
||||
setIsModalOpen,
|
||||
}) => {
|
||||
const isLastStep = currentStep === onboardingData.length - 1;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col'>
|
||||
<div className='onboarding_screens flex-col px-4 py-4'>
|
||||
<div className='flex-grow rounded-xl'>
|
||||
<Image unoptimized className='mx-auto shadow-md shadow-gray-200 rounded-lg aspect-auto' alt='' priority quality={100} src={step.imageSrc} />
|
||||
</div>
|
||||
<div className='grid grid-flow-col justify-stretch space-x-3 mt-4'>
|
||||
{onboardingData.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => goToStep(index)}
|
||||
className={`h-[7px] w-auto ${index === currentStep ? 'bg-black' : 'bg-gray-300'} hover:bg-gray-700 rounded-lg shadow-md cursor-pointer`}
|
||||
></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className='onboarding_text flex flex-col h-[90px] py-2 px-4 leading-tight'>
|
||||
<h2 className='text-xl font-bold'>{step.title}</h2>
|
||||
<p className='text-md font-normal'>{step.description}</p>
|
||||
</div>
|
||||
<div className='onboarding_actions flex flex-row-reverse w-full px-4'>
|
||||
<div className='flex flex-row justify-between w-full py-2'>
|
||||
<div className='utils_buttons flex flex-row space-x-2'>
|
||||
<div
|
||||
className="inline-flex items-center px-5 space-x-1 cursor-pointer py-1 rounded-full text-gray-600 antialiased font-bold bg-gray-100 hover:bg-gray-200"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
>
|
||||
<PictureInPicture size={16} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='actions_buttons flex space-x-2'>
|
||||
{step.buttons?.map((button, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="inline-flex items-center px-5 space-x-2 cursor-pointer py-1 rounded-full text-gray-200 antialiased font-bold bg-black hover:bg-gray-700 shadow-md whitespace-nowrap"
|
||||
onClick={button.action}
|
||||
>
|
||||
<p>{button.label}</p>
|
||||
{button.icon}
|
||||
</div>
|
||||
))}
|
||||
{isLastStep ? (
|
||||
<div
|
||||
className="inline-flex items-center px-5 space-x-2 cursor-pointer py-1 rounded-full text-gray-200 antialiased font-bold bg-black hover:bg-gray-700 shadow-md whitespace-nowrap"
|
||||
onClick={nextStep}
|
||||
>
|
||||
<p>Finish Onboarding</p>
|
||||
<Check size={16} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="inline-flex items-center px-5 space-x-2 cursor-pointer py-1 rounded-full text-gray-200 antialiased font-bold bg-black hover:bg-gray-700 shadow-md whitespace-nowrap"
|
||||
onClick={nextStep}
|
||||
>
|
||||
<p>Next</p>
|
||||
<ArrowRight size={16} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
'use client'
|
||||
|
||||
function NewCollectionButton() {
|
||||
return (
|
||||
<button className="rounded-lg bg-black hover:scale-105 transition-all duration-100 ease-linear antialiased ring-offset-purple-800 p-2 px-5 my-auto font text-xs font-bold text-white drop-shadow-lg flex space-x-2 items-center">
|
||||
<div>New Collection </div>
|
||||
<div className="text-md bg-neutral-800 px-1 rounded-full">+</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewCollectionButton
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
'use client'
|
||||
|
||||
function NewCourseButton() {
|
||||
return (
|
||||
<button className="rounded-lg bg-black hover:scale-105 transition-all duration-100 ease-linear antialiased ring-offset-purple-800 p-2 px-5 my-auto font text-xs font-bold text-white drop-shadow-lg flex space-x-2 items-center">
|
||||
<div>New Course </div>
|
||||
<div className="text-md bg-neutral-800 px-1 rounded-full">+</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewCourseButton
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
'use client'
|
||||
import React from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import { styled, keyframes } from '@stitches/react'
|
||||
import { blackA } from '@radix-ui/colors'
|
||||
import { AlertTriangle, Info } from 'lucide-react'
|
||||
|
||||
type ModalParams = {
|
||||
confirmationMessage: string
|
||||
confirmationButtonText: string
|
||||
dialogTitle: string
|
||||
functionToExecute: any
|
||||
dialogTrigger?: React.ReactNode
|
||||
status?: 'warning' | 'info'
|
||||
buttonid?: string
|
||||
}
|
||||
|
||||
const ConfirmationModal = (params: ModalParams) => {
|
||||
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
|
||||
const warningColors = 'bg-red-100 text-red-600'
|
||||
const infoColors = 'bg-blue-100 text-blue-600'
|
||||
const warningButtonColors = 'text-white bg-red-500 hover:bg-red-600'
|
||||
const infoButtonColors = 'text-white bg-blue-500 hover:bg-blue-600'
|
||||
|
||||
const onOpenChange = React.useCallback(
|
||||
(open: any) => {
|
||||
setIsDialogOpen(open)
|
||||
},
|
||||
[setIsDialogOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog.Root open={isDialogOpen} onOpenChange={onOpenChange}>
|
||||
{params.dialogTrigger ? (
|
||||
<Dialog.Trigger asChild>{params.dialogTrigger}</Dialog.Trigger>
|
||||
) : null}
|
||||
|
||||
<Dialog.Portal>
|
||||
<DialogOverlay />
|
||||
<DialogContent>
|
||||
<div className="h-26 flex space-x-4 tracking-tight">
|
||||
<div
|
||||
className={`icon p-6 rounded-xl flex items-center align-content-center ${
|
||||
params.status === 'warning' ? warningColors : infoColors
|
||||
}`}
|
||||
>
|
||||
{params.status === 'warning' ? (
|
||||
<AlertTriangle size={35} />
|
||||
) : (
|
||||
<Info size={35} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text pt-1 space-x-0 w-auto flex-grow">
|
||||
<div className="text-xl font-bold text-black ">
|
||||
{params.dialogTitle}
|
||||
</div>
|
||||
<div className="text-md text-gray-500 w-60 leading-tight">
|
||||
{params.confirmationMessage}
|
||||
</div>
|
||||
<div className="flex flex-row-reverse pt-2">
|
||||
<div
|
||||
id={params.buttonid}
|
||||
className={`rounded-md text-sm px-3 py-2 font-bold flex justify-center items-center hover:cursor-pointer ${
|
||||
params.status === 'warning'
|
||||
? warningButtonColors
|
||||
: infoButtonColors
|
||||
}
|
||||
hover:shadow-lg transition duration-300 ease-in-out
|
||||
`}
|
||||
onClick={() => {
|
||||
params.functionToExecute()
|
||||
setIsDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
{params.confirmationButtonText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const overlayShow = keyframes({
|
||||
'0%': { opacity: 0 },
|
||||
'100%': { opacity: 1 },
|
||||
})
|
||||
|
||||
const overlayClose = keyframes({
|
||||
'0%': { opacity: 1 },
|
||||
'100%': { opacity: 0 },
|
||||
})
|
||||
|
||||
const contentShow = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translate(-50%, -50%) scale(.96)' },
|
||||
'100%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' },
|
||||
})
|
||||
|
||||
const contentClose = keyframes({
|
||||
'0%': { opacity: 1, transform: 'translate(-50%, -50%) scale(1)' },
|
||||
'100%': { opacity: 0, transform: 'translate(-50%, -52%) scale(.96)' },
|
||||
})
|
||||
|
||||
const DialogOverlay = styled(Dialog.Overlay, {
|
||||
backgroundColor: blackA.blackA9,
|
||||
position: 'fixed',
|
||||
zIndex: 500,
|
||||
inset: 0,
|
||||
animation: `${overlayShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
'&[data-state="closed"]': {
|
||||
animation: `${overlayClose} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
},
|
||||
})
|
||||
|
||||
const DialogContent = styled(Dialog.Content, {
|
||||
backgroundColor: 'white',
|
||||
borderRadius: 18,
|
||||
zIndex: 501,
|
||||
boxShadow:
|
||||
'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
position: 'fixed',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: 'auto',
|
||||
minWidth: '500px',
|
||||
overflow: 'hidden',
|
||||
height: 'auto',
|
||||
maxHeight: '85vh',
|
||||
maxWidth: '600px',
|
||||
padding: 11,
|
||||
animation: `${contentShow} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
'&:focus': { outline: 'none' },
|
||||
|
||||
'&[data-state="closed"]': {
|
||||
animation: `${contentClose} 150ms cubic-bezier(0.16, 1, 0.3, 1)`,
|
||||
},
|
||||
transition: 'max-height 0.3s ease-out',
|
||||
})
|
||||
|
||||
export default ConfirmationModal
|
||||
45
apps/web/components/Objects/StyledElements/Error/Error.tsx
Normal file
45
apps/web/components/Objects/StyledElements/Error/Error.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use client'
|
||||
import { getUriWithoutOrg } from '@services/config/config'
|
||||
import { AlertTriangle, HomeIcon, RefreshCcw } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
function ErrorUI(params: { message?: string, submessage?: string }) {
|
||||
const router = useRouter()
|
||||
|
||||
function reloadPage() {
|
||||
router.refresh()
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col py-10 mx-auto antialiased items-center space-y-6 bg-gradient-to-b from-rose-100 to-rose-100/5 ">
|
||||
<div className="flex flex-row items-center space-x-5 rounded-xl ">
|
||||
<AlertTriangle className="text-rose-700" size={45} />
|
||||
<div className='flex flex-col'>
|
||||
<p className="text-3xl font-bold text-rose-700">{params.message ? params.message : 'Something went wrong'}</p>
|
||||
<p className="text-lg font-bold text-rose-700">{params.submessage ? params.submessage : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex space-x-4'>
|
||||
<button
|
||||
onClick={() => reloadPage()}
|
||||
className="flex space-x-2 items-center rounded-full px-4 py-1 text-rose-200 bg-rose-700 hover:bg-rose-800 transition-all ease-linear shadow-lg "
|
||||
>
|
||||
<RefreshCcw className="text-rose-200" size={17} />
|
||||
<span className="text-md font-bold">Retry</span>
|
||||
</button>
|
||||
<Link
|
||||
href={getUriWithoutOrg('/home')}
|
||||
className="flex space-x-2 items-center rounded-full px-4 py-1 text-gray-200 bg-gray-700 hover:bg-gray-800 transition-all ease-linear shadow-lg "
|
||||
>
|
||||
<HomeIcon className="text-gray-200" size={17} />
|
||||
<span className="text-md font-bold">Home</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorUI
|
||||
109
apps/web/components/Objects/StyledElements/Form/Form.tsx
Normal file
109
apps/web/components/Objects/StyledElements/Form/Form.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import React from 'react'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { styled } from '@stitches/react'
|
||||
import { blackA } from '@radix-ui/colors'
|
||||
import { Info } from 'lucide-react'
|
||||
|
||||
const FormLayout = (props: any, onSubmit: any) => (
|
||||
<FormRoot className="h-fit" onSubmit={props.onSubmit}>
|
||||
{props.children}
|
||||
</FormRoot>
|
||||
)
|
||||
|
||||
export const FormLabelAndMessage = (props: {
|
||||
label: string
|
||||
message?: string
|
||||
}) => (
|
||||
<div className="flex items-center space-x-3">
|
||||
<FormLabel className="grow text-sm">{props.label}</FormLabel>
|
||||
{(props.message && (
|
||||
<div className="text-red-700 text-sm items-center rounded-md flex space-x-1">
|
||||
<Info size={10} />
|
||||
<div>{props.message}</div>
|
||||
</div>
|
||||
)) || <></>}
|
||||
</div>
|
||||
)
|
||||
|
||||
export const FormRoot = styled(Form.Root, {
|
||||
margin: 7,
|
||||
})
|
||||
|
||||
export const FormField = styled(Form.Field, {
|
||||
display: 'grid',
|
||||
marginBottom: 10,
|
||||
})
|
||||
|
||||
export const FormLabel = styled(Form.Label, {
|
||||
fontWeight: 500,
|
||||
lineHeight: '35px',
|
||||
color: 'black',
|
||||
})
|
||||
|
||||
export const FormMessage = styled(Form.Message, {
|
||||
fontSize: 13,
|
||||
color: 'white',
|
||||
opacity: 0.8,
|
||||
})
|
||||
|
||||
export const Flex = styled('div', { display: 'flex' })
|
||||
|
||||
export const inputStyles = {
|
||||
all: 'unset',
|
||||
boxSizing: 'border-box',
|
||||
width: '100%',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
fontSize: 15,
|
||||
color: '#7c7c7c',
|
||||
background: '#fbfdff',
|
||||
boxShadow: `0 0 0 1px #edeeef`,
|
||||
'&:hover': { boxShadow: `0 0 0 1px #edeeef` },
|
||||
'&:focus': { boxShadow: `0 0 0 2px #edeeef` },
|
||||
'&::selection': { backgroundColor: blackA.blackA9, color: 'white' },
|
||||
}
|
||||
|
||||
export const Input = styled('input', {
|
||||
...inputStyles,
|
||||
height: 35,
|
||||
lineHeight: 1,
|
||||
padding: '0 10px',
|
||||
border: 'none',
|
||||
})
|
||||
|
||||
export const Textarea = styled('textarea', {
|
||||
...inputStyles,
|
||||
resize: 'none',
|
||||
padding: 10,
|
||||
})
|
||||
|
||||
export const ButtonBlack = styled('button', {
|
||||
variants: {
|
||||
state: {
|
||||
loading: {
|
||||
pointerEvents: 'none',
|
||||
backgroundColor: '#808080',
|
||||
},
|
||||
none: {},
|
||||
},
|
||||
},
|
||||
all: 'unset',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 8,
|
||||
padding: '0 15px',
|
||||
fontSize: 15,
|
||||
lineHeight: 1,
|
||||
fontWeight: 500,
|
||||
height: 35,
|
||||
|
||||
background: '#000000',
|
||||
color: '#FFFFFF',
|
||||
'&:hover': { backgroundColor: '#181818', cursor: 'pointer' },
|
||||
'&:focus': { boxShadow: `0 0 0 2px black` },
|
||||
})
|
||||
|
||||
export default FormLayout
|
||||
37
apps/web/components/Objects/StyledElements/Info/Info.tsx
Normal file
37
apps/web/components/Objects/StyledElements/Info/Info.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
'use client'
|
||||
import { getUriWithoutOrg } from '@services/config/config'
|
||||
import { Diamond, Home, PersonStanding } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
function InfoUI(params: { message?: string, submessage?: string, cta?: string, href: string }) {
|
||||
return (
|
||||
<div className="flex flex-col py-10 mx-auto antialiased items-center space-y-6 bg-gradient-to-b from-yellow-100 to-yellow-100/5 ">
|
||||
<div className="flex flex-row items-center space-x-5 rounded-xl ">
|
||||
<Diamond className="text-yellow-700" size={45} />
|
||||
<div className='flex flex-col'>
|
||||
<p className="text-3xl font-bold text-yellow-700">{params.message ? params.message : 'Something went wrong'}</p>
|
||||
<p className="text-lg font-bold text-yellow-700">{params.submessage ? params.submessage : ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
{params.cta && <div className='flex space-x-4'>
|
||||
<Link
|
||||
href={params.href}
|
||||
className="flex space-x-2 items-center rounded-full px-4 py-1 text-yellow-200 bg-yellow-700 hover:bg-yellow-800 transition-all ease-linear shadow-lg "
|
||||
>
|
||||
<PersonStanding className="text-yellow-200" size={17} />
|
||||
<span className="text-md font-bold">{params.cta}</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={getUriWithoutOrg('/home')}
|
||||
className="flex space-x-2 items-center rounded-full px-4 py-1 text-gray-200 bg-gray-700 hover:bg-gray-800 transition-all ease-linear shadow-lg "
|
||||
>
|
||||
<Home className="text-gray-200" size={17} />
|
||||
<span className="text-md font-bold">Home</span>
|
||||
</Link>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InfoUI
|
||||
80
apps/web/components/Objects/StyledElements/Modal/Modal.tsx
Normal file
80
apps/web/components/Objects/StyledElements/Modal/Modal.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@components/ui/dialog"
|
||||
import { ButtonBlack } from '../Form/Form'
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ModalParams = {
|
||||
dialogTitle?: string
|
||||
dialogDescription?: string
|
||||
dialogContent: React.ReactNode
|
||||
dialogClose?: React.ReactNode | null
|
||||
dialogTrigger?: React.ReactNode
|
||||
addDefCloseButton?: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
isDialogOpen?: boolean
|
||||
minHeight?: 'sm' | 'md' | 'lg' | 'xl' | 'no-min'
|
||||
minWidth?: 'sm' | 'md' | 'lg' | 'xl' | 'no-min'
|
||||
customHeight?: string
|
||||
customWidth?: string
|
||||
}
|
||||
|
||||
const Modal = (params: ModalParams) => {
|
||||
const getMinHeight = () => {
|
||||
switch (params.minHeight) {
|
||||
case 'sm': return 'min-h-[300px]'
|
||||
case 'md': return 'min-h-[500px]'
|
||||
case 'lg': return 'min-h-[700px]'
|
||||
case 'xl': return 'min-h-[900px]'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getMinWidth = () => {
|
||||
switch (params.minWidth) {
|
||||
case 'sm': return 'min-w-[600px]'
|
||||
case 'md': return 'min-w-[800px]'
|
||||
case 'lg': return 'min-w-[1000px]'
|
||||
case 'xl': return 'min-w-[1200px]'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={params.isDialogOpen} onOpenChange={params.onOpenChange}>
|
||||
{params.dialogTrigger && (
|
||||
<DialogTrigger asChild>{params.dialogTrigger}</DialogTrigger>
|
||||
)}
|
||||
<DialogContent className={cn(
|
||||
"overflow-auto",
|
||||
getMinHeight(),
|
||||
getMinWidth(),
|
||||
params.customHeight,
|
||||
params.customWidth
|
||||
)}>
|
||||
{params.dialogTitle && params.dialogDescription && (
|
||||
<DialogHeader className="text-center flex flex-col space-y-0.5 w-full">
|
||||
<DialogTitle>{params.dialogTitle}</DialogTitle>
|
||||
<DialogDescription>{params.dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
<div className="overflow-auto">
|
||||
{params.dialogContent}
|
||||
</div>
|
||||
{(params.dialogClose || params.addDefCloseButton) && (
|
||||
<DialogFooter>
|
||||
{params.dialogClose}
|
||||
{params.addDefCloseButton && (
|
||||
<ButtonBlack type="submit">
|
||||
Close
|
||||
</ButtonBlack>
|
||||
)}
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default Modal
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import Image from 'next/image'
|
||||
import CoursesLogo from 'public/svg/courses.svg'
|
||||
import CollectionsLogo from 'public/svg/collections.svg'
|
||||
import TrailLogo from 'public/svg/trail.svg'
|
||||
|
||||
function TypeOfContentTitle(props: { title: string; type: string }) {
|
||||
function getLogo() {
|
||||
if (props.type == 'col') {
|
||||
return CollectionsLogo
|
||||
} else if (props.type == 'cou') {
|
||||
return CoursesLogo
|
||||
} else if (props.type == 'tra') {
|
||||
return TrailLogo
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home_category_title flex my-5 items-center">
|
||||
<div className="ml-2 rounded-full ring-1 ring-slate-900/5 shadow-inner p-2 my-auto mr-4">
|
||||
<Image unoptimized className="" src={getLogo()} alt="Courses logo" />
|
||||
</div>
|
||||
<h1 className="font-bold text-2xl">{props.title}</h1>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TypeOfContentTitle
|
||||
13
apps/web/components/Objects/StyledElements/Toast/Toast.tsx
Normal file
13
apps/web/components/Objects/StyledElements/Toast/Toast.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
'use client'
|
||||
import React from 'react'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
|
||||
function Toast() {
|
||||
return (
|
||||
<>
|
||||
<Toaster />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toast
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
'use client'
|
||||
import React from 'react'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import { styled, keyframes } from '@stitches/react'
|
||||
|
||||
type TooltipProps = {
|
||||
sideOffset?: number
|
||||
content: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: 'top' | 'right' | 'bottom' | 'left' // default is bottom
|
||||
slateBlack?: boolean
|
||||
}
|
||||
|
||||
const ToolTip = (props: TooltipProps) => {
|
||||
return (
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>{props.children}</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<TooltipContent
|
||||
slateBlack={props.slateBlack}
|
||||
side={props.side ? props.side : 'bottom'}
|
||||
sideOffset={props.sideOffset}
|
||||
>
|
||||
{props.content}
|
||||
</TooltipContent>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const slideUpAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateY(2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateY(0)' },
|
||||
})
|
||||
|
||||
const slideRightAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateX(-2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateX(0)' },
|
||||
})
|
||||
|
||||
const slideDownAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateY(-2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateY(0)' },
|
||||
})
|
||||
|
||||
const slideLeftAndFade = keyframes({
|
||||
'0%': { opacity: 0, transform: 'translateX(2px)' },
|
||||
'100%': { opacity: 1, transform: 'translateX(0)' },
|
||||
})
|
||||
|
||||
const closeAndFade = keyframes({
|
||||
'0%': { opacity: 1 },
|
||||
'100%': { opacity: 0 },
|
||||
})
|
||||
|
||||
const TooltipContent = styled(Tooltip.Content, {
|
||||
variants: {
|
||||
slateBlack: {
|
||||
true: {
|
||||
backgroundColor: ' #0d0d0d',
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
borderRadius: 4,
|
||||
padding: '5px 10px',
|
||||
fontSize: 12,
|
||||
lineHeight: 1,
|
||||
color: 'black',
|
||||
backgroundColor: 'rgba(217, 217, 217, 0.50)',
|
||||
zIndex: 500,
|
||||
boxShadow:
|
||||
'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px',
|
||||
userSelect: 'none',
|
||||
animationDuration: '400ms',
|
||||
animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
willChange: 'transform, opacity',
|
||||
'&[data-state="delayed-open"]': {
|
||||
'&[data-side="top"]': { animationName: slideDownAndFade },
|
||||
'&[data-side="right"]': { animationName: slideLeftAndFade },
|
||||
'&[data-side="bottom"]': { animationName: slideUpAndFade },
|
||||
'&[data-side="left"]': { animationName: slideRightAndFade },
|
||||
},
|
||||
|
||||
// closing animation
|
||||
'&[data-state="closed"]': {
|
||||
'&[data-side="top"]': { animationName: closeAndFade },
|
||||
'&[data-side="right"]': { animationName: closeAndFade },
|
||||
'&[data-side="bottom"]': { animationName: closeAndFade },
|
||||
'&[data-side="left"]': { animationName: closeAndFade },
|
||||
},
|
||||
})
|
||||
|
||||
export default ToolTip
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
function GeneralWrapperStyled({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="max-w-screen-2xl mx-auto px-4 sm:px-6 lg:px-8 py-5 tracking-tight z-50">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GeneralWrapperStyled
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
|
||||
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { deleteCollection } from '@services/courses/collections'
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
|
||||
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { deleteCourseFromBackend } from '@services/courses/courses'
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
|
|
@ -17,7 +17,7 @@ import {
|
|||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
} from "@components/ui/dropdown-menu"
|
||||
|
||||
type Course = {
|
||||
course_uuid: string
|
||||
|
|
|
|||
27
apps/web/components/Objects/Watermark.tsx
Normal file
27
apps/web/components/Objects/Watermark.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import blacklogo from '@public/black_logo.png'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useOrg } from '../Contexts/OrgContext'
|
||||
|
||||
function Watermark() {
|
||||
const org = useOrg() as any
|
||||
|
||||
useEffect(() => {
|
||||
}
|
||||
, [org]);
|
||||
|
||||
if (org?.config?.config?.general?.watermark) {
|
||||
return (
|
||||
<div className='fixed bottom-8 right-8'>
|
||||
<Link href={`https://www.learnhouse.app/?source=in-app`} className="flex items-center cursor-pointer bg-white/80 backdrop-blur-lg text-gray-700 rounded-2xl p-2 light-shadow text-xs px-5 font-semibold space-x-2">
|
||||
<p>Made with</p>
|
||||
<Image unoptimized src={blacklogo} alt="logo" quality={100} width={85} />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default Watermark
|
||||
Loading…
Add table
Add a link
Reference in a new issue