mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: add course checkout UI and stripe integration and webhook wip
This commit is contained in:
parent
d8913d1a60
commit
1bff401e73
18 changed files with 1086 additions and 131 deletions
|
|
@ -1,5 +1,4 @@
|
|||
'use client'
|
||||
import { removeCourse, startCourse } from '@services/courses/activity'
|
||||
import Link from 'next/link'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
|
|
@ -15,15 +14,13 @@ import {
|
|||
import { ArrowRight, Backpack, Check, File, Sparkles, Video } from 'lucide-react'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import UserAvatar from '@components/Objects/UserAvatar'
|
||||
import CourseUpdates from '@components/Objects/CourseUpdates/CourseUpdates'
|
||||
import CourseUpdates from '@components/Objects/Courses/CourseUpdates/CourseUpdates'
|
||||
import { CourseProvider } from '@components/Contexts/CourseContext'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useMediaQuery } from 'usehooks-ts'
|
||||
import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions'
|
||||
|
||||
const CourseClient = (props: any) => {
|
||||
const [user, setUser] = useState<any>({})
|
||||
const [learnings, setLearnings] = useState<any>([])
|
||||
const session = useLHSession() as any;
|
||||
const courseuuid = props.courseuuid
|
||||
const orgslug = props.orgslug
|
||||
const course = props.course
|
||||
|
|
@ -37,33 +34,6 @@ const CourseClient = (props: any) => {
|
|||
setLearnings(learnings)
|
||||
}
|
||||
|
||||
async function startCourseUI() {
|
||||
// Create activity
|
||||
await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
|
||||
await revalidateTags(['courses'], orgslug)
|
||||
router.refresh()
|
||||
|
||||
// refresh page (FIX for Next.js BUG)
|
||||
// window.location.reload();
|
||||
}
|
||||
|
||||
function isCourseStarted() {
|
||||
const runs = course.trail?.runs
|
||||
if (!runs) return false
|
||||
return runs.some(
|
||||
(run: any) =>
|
||||
run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id
|
||||
)
|
||||
}
|
||||
|
||||
async function quitCourse() {
|
||||
// Close activity
|
||||
let activity = await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
|
||||
// Mutate course
|
||||
await revalidateTags(['courses'], orgslug)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getLearningTags()
|
||||
}, [org, course])
|
||||
|
|
@ -80,7 +50,7 @@ const CourseClient = (props: any) => {
|
|||
<h1 className="text-3xl md:text-3xl -mt-3 font-bold">{course.name}</h1>
|
||||
</div>
|
||||
<div className="mt-4 md:mt-0">
|
||||
{!isMobile && <CourseProvider courseuuid={course.course_uuid}>
|
||||
{!isMobile && <CourseProvider courseuuid={course.course_uuid}>
|
||||
<CourseUpdates />
|
||||
</CourseProvider>}
|
||||
</div>
|
||||
|
|
@ -113,11 +83,11 @@ const CourseClient = (props: any) => {
|
|||
course={course}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col md:flex-row pt-10">
|
||||
<div className="course_metadata_left grow space-y-2">
|
||||
<h2 className="py-3 text-2xl font-bold">Description</h2>
|
||||
<div className="flex flex-col md:flex-row md:space-x-10 space-y-6 md:space-y-0 pt-10">
|
||||
<div className="course_metadata_left w-full md:basis-3/4 space-y-2">
|
||||
<h2 className="py-3 text-2xl font-bold">About</h2>
|
||||
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
|
||||
<p className="py-5 px-5">{course.description}</p>
|
||||
<p className="py-5 px-5 whitespace-pre-wrap">{course.about}</p>
|
||||
</div>
|
||||
|
||||
{learnings.length > 0 && learnings[0] !== 'null' && (
|
||||
|
|
@ -187,7 +157,7 @@ const CourseClient = (props: any) => {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{activity.activity_type ===
|
||||
{activity.activity_type ===
|
||||
'TYPE_ASSIGNMENT' && (
|
||||
<div className="bg-gray-100 px-2 py-2 rounded-full">
|
||||
<Backpack
|
||||
|
|
@ -273,7 +243,7 @@ const CourseClient = (props: any) => {
|
|||
</Link>
|
||||
</>
|
||||
)}
|
||||
{activity.activity_type ===
|
||||
{activity.activity_type ===
|
||||
'TYPE_ASSIGNMENT' && (
|
||||
<>
|
||||
<Link
|
||||
|
|
@ -305,60 +275,8 @@ const CourseClient = (props: any) => {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="course_metadata_right space-y-3 w-full md:w-72 antialiased flex flex-col md:ml-10 h-fit p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden mt-6 md:mt-0">
|
||||
{user && (
|
||||
<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={course.authors[0].avatar_image ? getUserAvatarMediaDirectory(course.authors[0].user_uuid, course.authors[0].avatar_image) : ''}
|
||||
predefined_avatar={course.authors[0].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">
|
||||
{course.authors[0].first_name &&
|
||||
course.authors[0].last_name && (
|
||||
<div className="flex space-x-2 items-center">
|
||||
<p>
|
||||
{course.authors[0].first_name +
|
||||
' ' +
|
||||
course.authors[0].last_name}
|
||||
</p>
|
||||
<span className="text-xs bg-neutral-100 p-1 px-3 rounded-full text-neutral-400 font-semibold">
|
||||
{' '}
|
||||
@{course.authors[0].username}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!course.authors[0].first_name &&
|
||||
!course.authors[0].last_name && (
|
||||
<div className="flex space-x-2 items-center">
|
||||
<p>@{course.authors[0].username}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCourseStarted() ? (
|
||||
<button
|
||||
className="py-2 px-5 mx-auto rounded-xl text-white font-bold h-12 w-full md:w-[200px] drop-shadow-md bg-red-600 hover:bg-red-700 hover:cursor-pointer"
|
||||
onClick={quitCourse}
|
||||
>
|
||||
Quit Course
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="py-2 px-5 mx-auto rounded-xl text-white font-bold h-12 w-full md:w-[200px] drop-shadow-md bg-black hover:bg-gray-900 hover:cursor-pointer"
|
||||
onClick={startCourseUI}
|
||||
>
|
||||
Start Course
|
||||
</button>
|
||||
)}
|
||||
<div className='course_metadata_right basis-1/4'>
|
||||
<CoursesActions courseuuid={courseuuid} orgslug={orgslug} course={course} />
|
||||
</div>
|
||||
</div>
|
||||
</GeneralWrapperStyled>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -73,4 +73,21 @@ export async function getCoursesLinkedToProduct(orgId: number, productId: string
|
|||
return res;
|
||||
}
|
||||
|
||||
export async function getProductsByCourse(orgId: number, courseId: string, access_token: string) {
|
||||
const result = await fetch(
|
||||
`${getAPIUrl()}payments/${orgId}/courses/${courseId}/products`,
|
||||
RequestBodyWithAuthHeader('GET', null, null, access_token)
|
||||
);
|
||||
const res = await getResponseMetadata(result);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function getStripeProductCheckoutSession(orgId: number, productId: number, redirect_uri: string, access_token: string) {
|
||||
const result = await fetch(
|
||||
`${getAPIUrl()}payments/${orgId}/stripe/checkout/product/${productId}?redirect_uri=${redirect_uri}`,
|
||||
RequestBodyWithAuthHeader('POST', null, null, access_token)
|
||||
);
|
||||
const res = await getResponseMetadata(result);
|
||||
return res;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue