Merge pull request #415 from learnhouse/feat/explore

Explore Marketplace features
This commit is contained in:
Badr B. 2025-01-25 13:05:08 +01:00 committed by GitHub
commit 8729751830
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 2250 additions and 524 deletions

View file

@ -0,0 +1,41 @@
"""Organizations new model
Revision ID: 87a621284ae4
Revises: 0314ec7791e1
Create Date: 2024-12-17 22:51:50.998443
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa # noqa: F401
import sqlmodel # noqa: F401
# revision identifiers, used by Alembic.
revision: str = '87a621284ae4'
down_revision: Union[str, None] = '0314ec7791e1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('organization', sa.Column('about', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('organization', sa.Column('socials', sa.JSON(), nullable=True))
op.add_column('organization', sa.Column('links', sa.JSON(), nullable=True))
op.add_column('organization', sa.Column('previews', sa.JSON(), nullable=True))
op.add_column('organization', sa.Column('explore', sa.Boolean(), nullable=True))
op.add_column('organization', sa.Column('label', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('organization', 'label')
op.drop_column('organization', 'explore')
op.drop_column('organization', 'previews')
op.drop_column('organization', 'links')
op.drop_column('organization', 'socials')
op.drop_column('organization', 'about')
# ### end Alembic commands ###

View file

@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel, JSON, Column
from src.db.roles import RoleRead from src.db.roles import RoleRead
from src.db.organization_config import OrganizationConfig from src.db.organization_config import OrganizationConfig
@ -9,10 +9,16 @@ from src.db.organization_config import OrganizationConfig
class OrganizationBase(SQLModel): class OrganizationBase(SQLModel):
name: str name: str
description: Optional[str] description: Optional[str]
slug: str about: Optional[str]
email: str socials: Optional[dict] = Field(default={}, sa_column=Column(JSON))
links: Optional[dict] = Field(default={}, sa_column=Column(JSON))
logo_image: Optional[str] logo_image: Optional[str]
thumbnail_image: Optional[str] thumbnail_image: Optional[str]
previews: Optional[dict] = Field(default={}, sa_column=Column(JSON))
explore: Optional[bool] = Field(default=False)
label: Optional[str]
slug: str
email: str
class Organization(OrganizationBase, table=True): class Organization(OrganizationBase, table=True):
@ -26,9 +32,19 @@ class OrganizationWithConfig(BaseModel):
config: OrganizationConfig config: OrganizationConfig
class OrganizationUpdate(OrganizationBase): class OrganizationUpdate(SQLModel):
pass name: Optional[str] = None
description: Optional[str] = None
about: Optional[str] = None
socials: Optional[dict] = None
links: Optional[dict] = None
logo_image: Optional[str] = None
thumbnail_image: Optional[str] = None
previews: Optional[dict] = None
label: Optional[str] = None
slug: Optional[str] = None
email: Optional[str] = None
explore: Optional[bool] = None
class OrganizationCreate(OrganizationBase): class OrganizationCreate(OrganizationBase):
pass pass

View file

@ -1,8 +1,10 @@
import os import os
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session from sqlmodel import Session
from src.core.events.database import get_db_session from src.core.events.database import get_db_session
from src.db.organization_config import OrganizationConfigBase from src.db.organization_config import OrganizationConfigBase
from src.services.explore.explore import get_course_for_explore, get_courses_for_an_org_explore, get_org_for_explore, get_orgs_for_explore, search_orgs_for_explore
from src.services.orgs.orgs import update_org_with_config_no_auth from src.services.orgs.orgs import update_org_with_config_no_auth
router = APIRouter() router = APIRouter()
@ -14,6 +16,49 @@ def check_internal_cloud_key(request: Request):
): ):
raise HTTPException(status_code=403, detail="Unauthorized") raise HTTPException(status_code=403, detail="Unauthorized")
@router.get("/explore/orgs")
async def api_get_orgs_for_explore(
request: Request,
page: int = 1,
limit: int = 10,
label: str = "",
salt: str = "",
db_session: Session = Depends(get_db_session),
):
return await get_orgs_for_explore(request, db_session, page, limit, label, salt)
@router.get("/explore/orgs/search")
async def api_search_orgs_for_explore(
request: Request,
search_query: str,
label: Optional[str] = None,
db_session: Session = Depends(get_db_session),
):
return await search_orgs_for_explore(request, db_session, search_query, label)
@router.get("/explore/orgs/{org_uuid}/courses")
async def api_get_courses_for_explore(
request: Request,
org_uuid: str,
db_session: Session = Depends(get_db_session),
):
return await get_courses_for_an_org_explore(request, db_session, org_uuid)
@router.get("/explore/courses/{course_id}")
async def api_get_course_for_explore(
request: Request,
course_id: str,
db_session: Session = Depends(get_db_session),
):
return await get_course_for_explore(request, course_id, db_session)
@router.get("/explore/orgs/{org_slug}")
async def api_get_org_for_explore(
request: Request,
org_slug: str,
db_session: Session = Depends(get_db_session),
):
return await get_org_for_explore(request, org_slug, db_session)
@router.put("/update_org_config") @router.put("/update_org_config")
async def update_org_Config( async def update_org_Config(

View file

@ -37,6 +37,7 @@ from src.services.orgs.orgs import (
get_orgs_by_user_admin, get_orgs_by_user_admin,
update_org, update_org,
update_org_logo, update_org_logo,
update_org_preview,
update_org_signup_mechanism, update_org_signup_mechanism,
update_org_thumbnail, update_org_thumbnail,
) )
@ -334,6 +335,25 @@ async def api_update_org_thumbnail(
db_session=db_session, db_session=db_session,
) )
@router.put("/{org_id}/preview")
async def api_update_org_preview(
request: Request,
org_id: str,
preview_file: UploadFile,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Update org thumbnail
"""
return await update_org_preview(
request=request,
preview_file=preview_file,
org_id=org_id,
current_user=current_user,
db_session=db_session,
)
@router.get("/user/page/{page}/limit/{limit}") @router.get("/user/page/{page}/limit/{limit}")
async def api_user_orgs( async def api_user_orgs(

View file

@ -47,15 +47,23 @@ async def get_collection(
# get courses in collection # get courses in collection
statement_all = ( statement_all = (
select(Course) select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id) .join(CollectionCourse)
.where(CollectionCourse.org_id == collection.org_id) .where(
.distinct(Course.id) CollectionCourse.collection_id == collection.id,
CollectionCourse.org_id == collection.org_id
)
.distinct()
) )
statement_public = ( statement_public = (
select(Course) select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id) .join(CollectionCourse)
.where(CollectionCourse.org_id == collection.org_id, Course.public == True) .where(
CollectionCourse.collection_id == collection.id,
CollectionCourse.org_id == collection.org_id,
Course.public == True
)
.distinct()
) )
if current_user.user_uuid == "user_anonymous": if current_user.user_uuid == "user_anonymous":
@ -63,7 +71,7 @@ async def get_collection(
else: else:
statement = statement_all statement = statement_all
courses = db_session.exec(statement).all() courses = list(db_session.exec(statement).all())
collection = CollectionRead(**collection.model_dump(), courses=courses) collection = CollectionRead(**collection.model_dump(), courses=courses)
@ -110,10 +118,11 @@ async def create_collection(
# Get courses once again # Get courses once again
statement = ( statement = (
select(Course) select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id) .join(CollectionCourse)
.distinct(Course.id) .where(CollectionCourse.collection_id == collection.id)
.distinct()
) )
courses = db_session.exec(statement).all() courses = list(db_session.exec(statement).all())
collection = CollectionRead(**collection.model_dump(), courses=courses) collection = CollectionRead(**collection.model_dump(), courses=courses)
@ -183,12 +192,11 @@ async def update_collection(
# Get courses once again # Get courses once again
statement = ( statement = (
select(Course) select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id) .join(CollectionCourse)
.where(Course.org_id == collection.org_id) .where(CollectionCourse.collection_id == collection.id)
.distinct(Course.id) .distinct()
) )
courses = list(db_session.exec(statement).all())
courses = db_session.exec(statement).all()
collection = CollectionRead(**collection.model_dump(), courses=courses) collection = CollectionRead(**collection.model_dump(), courses=courses)
@ -255,14 +263,22 @@ async def get_collections(
for collection in collections: for collection in collections:
statement_all = ( statement_all = (
select(Course) select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id) .join(CollectionCourse)
.where(CollectionCourse.org_id == collection.org_id) .where(
.distinct(Course.id) CollectionCourse.collection_id == collection.id,
CollectionCourse.org_id == collection.org_id
)
.distinct()
) )
statement_public = ( statement_public = (
select(Course) select(Course)
.join(CollectionCourse, Course.id == CollectionCourse.course_id) .join(CollectionCourse)
.where(CollectionCourse.org_id == org_id, Course.public == True) .where(
CollectionCourse.collection_id == collection.id,
CollectionCourse.org_id == org_id,
Course.public == True
)
.distinct()
) )
if current_user.id == 0: if current_user.id == 0:
statement = statement_public statement = statement_public

View file

@ -0,0 +1,165 @@
from typing import Optional
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from sqlalchemy import text
from src.db.courses.courses import Course, CourseRead
from src.db.organizations import Organization, OrganizationRead
def _get_sort_expression(salt: str):
"""Helper function to create consistent sort expression"""
if not salt:
return Organization.name
# Create a deterministic ordering using md5(salt + id)
return text(
f"md5('{salt}' || id)"
)
async def get_orgs_for_explore(
request: Request,
db_session: Session,
page: int = 1,
limit: int = 10,
label: str = "",
salt: str = "",
) -> list[OrganizationRead]:
statement = (
select(Organization)
.where(
Organization.explore == True,
)
)
# Add label filter if provided
if label:
statement = statement.where(Organization.label == label) #type: ignore
# Add deterministic ordering based on salt
statement = statement.order_by(_get_sort_expression(salt))
# Add pagination
statement = (
statement
.offset((page - 1) * limit)
.limit(limit)
)
result = db_session.exec(statement)
orgs = result.all()
return [OrganizationRead.model_validate(org) for org in orgs]
async def get_courses_for_an_org_explore(
request: Request,
db_session: Session,
org_uuid: str,
) -> list[CourseRead]:
statement = select(Organization).where(Organization.org_uuid == org_uuid)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
statement = select(Course).where(Course.org_id == org.id, Course.public == True)
result = db_session.exec(statement)
courses = result.all()
courses_list = []
for course in courses:
courses_list.append(course)
return courses_list
async def get_course_for_explore(
request: Request,
course_id: str,
db_session: Session,
) -> CourseRead:
statement = select(Course).where(Course.id == course_id)
result = db_session.exec(statement)
course = result.first()
if not course:
raise HTTPException(
status_code=404,
detail="Course not found",
)
return CourseRead.model_validate(course)
async def search_orgs_for_explore(
request: Request,
db_session: Session,
search_query: str,
label: Optional[str] = None,
page: int = 1,
limit: int = 10,
salt: str = "",
) -> list[OrganizationRead]:
# Create a combined search vector
search_terms = search_query.split()
search_conditions = []
for term in search_terms:
term_pattern = f"%{term}%"
search_conditions.append(
(Organization.name.ilike(term_pattern)) | #type: ignore
(Organization.about.ilike(term_pattern)) | #type: ignore
(Organization.description.ilike(term_pattern)) | #type: ignore
(Organization.label.ilike(term_pattern)) #type: ignore
)
statement = (
select(Organization)
.where(Organization.explore == True)
)
if label and label != "all":
statement = statement.where(Organization.label == label) #type: ignore
if search_conditions:
statement = statement.where(*search_conditions)
# Add deterministic ordering based on salt
statement = statement.order_by(_get_sort_expression(salt))
# Add pagination
statement = (
statement
.offset((page - 1) * limit)
.limit(limit)
)
result = db_session.exec(statement)
orgs = result.all()
return [OrganizationRead.model_validate(org) for org in orgs]
async def get_org_for_explore(
request: Request,
org_slug: str,
db_session: Session,
) -> OrganizationRead:
statement = select(Organization).where(Organization.slug == org_slug)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
return OrganizationRead.model_validate(org)

View file

@ -36,7 +36,7 @@ from src.db.organizations import (
) )
from fastapi import HTTPException, UploadFile, status, Request from fastapi import HTTPException, UploadFile, status, Request
from src.services.orgs.uploads import upload_org_logo, upload_org_thumbnail from src.services.orgs.uploads import upload_org_logo, upload_org_preview, upload_org_thumbnail
async def get_organization( async def get_organization(
@ -174,7 +174,7 @@ async def create_org(
storage=StorageOrgConfig(enabled=True, limit=0), storage=StorageOrgConfig(enabled=True, limit=0),
ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"), ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"),
assignments=AssignmentOrgConfig(enabled=True, limit=0), assignments=AssignmentOrgConfig(enabled=True, limit=0),
payments=PaymentOrgConfig(enabled=True, stripe_key=""), payments=PaymentOrgConfig(enabled=True),
discussions=DiscussionOrgConfig(enabled=True, limit=0), discussions=DiscussionOrgConfig(enabled=True, limit=0),
analytics=AnalyticsOrgConfig(enabled=True, limit=0), analytics=AnalyticsOrgConfig(enabled=True, limit=0),
collaboration=CollaborationOrgConfig(enabled=True, limit=0), collaboration=CollaborationOrgConfig(enabled=True, limit=0),
@ -458,6 +458,31 @@ async def update_org_thumbnail(
return {"detail": "Thumbnail updated"} return {"detail": "Thumbnail updated"}
async def update_org_preview(
request: Request,
preview_file: UploadFile,
org_id: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
statement = select(Organization).where(Organization.id == org_id)
result = db_session.exec(statement)
org = result.first()
if not org:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "update", db_session)
# Upload logo
name_in_disk = await upload_org_preview(preview_file, org.org_uuid)
return {"name_in_disk": name_in_disk}
async def delete_org( async def delete_org(
request: Request, request: Request,
@ -675,6 +700,19 @@ async def get_org_join_mechanism(
return signup_mechanism return signup_mechanism
async def upload_org_preview_service(
preview_file: UploadFile,
org_uuid: str,
) -> dict:
# No need for request or current_user since we're not doing RBAC checks for previews
# Upload preview
name_in_disk = await upload_org_preview(preview_file, org_uuid)
return {
"detail": "Preview uploaded successfully",
"filename": name_in_disk
}
## 🔒 RBAC Utils ## ## 🔒 RBAC Utils ##

View file

@ -31,3 +31,18 @@ async def upload_org_thumbnail(thumbnail_file, org_uuid):
) )
return name_in_disk return name_in_disk
async def upload_org_preview(file, org_uuid: str) -> str:
contents = file.file.read()
name_in_disk = f"{uuid4()}.{file.filename.split('.')[-1]}"
await upload_content(
"previews",
"orgs",
org_uuid,
contents,
name_in_disk,
)
return name_in_disk

View file

@ -39,7 +39,9 @@ def send_password_reset_email(
<html> <html>
<body> <body>
<p>Hello {user.username}</p> <p>Hello {user.username}</p>
<p>Click <a href="https://{organization.slug}.learnhouse.io/reset?email={email}&resetCode={generated_reset_code}">here</a> to reset your password.</p> <p>You have requested to reset your password.</p>
<p>Here is your reset code: {generated_reset_code}</p>
<p>Click <a href="https://{organization.slug}.learnhouse.io/reset?orgslug={organization.slug}&email={email}&resetCode={generated_reset_code}">here</a> to reset your password.</p>
</body> </body>
</html> </html>
""", """,

View file

@ -11,7 +11,7 @@ import * as Form from '@radix-ui/react-form'
import { getOrgLogoMediaDirectory } from '@services/media/media' import { getOrgLogoMediaDirectory } from '@services/media/media'
import { AlertTriangle, Info } from 'lucide-react' import { AlertTriangle, Info } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { useFormik } from 'formik' import { useFormik } from 'formik'
@ -139,9 +139,14 @@ function ResetPasswordClient() {
</div> </div>
)} )}
{message && ( {message && (
<div className="flex justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm"> <div className="flex flex-col gap-2">
<Info size={18} /> <div className="flex justify-center bg-green-200 rounded-md text-green-950 space-x-2 items-center p-4 transition-all shadow-sm">
<div className="font-bold text-sm">{message}</div> <Info size={18} />
<div className="font-bold text-sm">{message}</div>
</div>
<Link href={getUriWithoutOrg('/login?orgslug=' + org.slug)} className="text-center text-sm text-blue-600 hover:text-blue-800">
Please login again with your new password
</Link>
</div> </div>
)} )}
<FormLayout onSubmit={formik.handleSubmit}> <FormLayout onSubmit={formik.handleSubmit}>

View file

@ -7,17 +7,22 @@ import { getAPIUrl, getUriWithOrg } from '@services/config/config'
import { revalidateTags, swrFetcher } from '@services/utils/ts/requests' import { revalidateTags, swrFetcher } from '@services/utils/ts/requests'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { Loader2, Image as ImageIcon } from 'lucide-react'
import { toast } from 'react-hot-toast'
import Image from 'next/image'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
function NewCollection(params: any) { function NewCollection(params: any) {
const org = useOrg() as any const org = useOrg() as any
const session = useLHSession() as any; const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token; const access_token = session?.data?.tokens?.access_token
const orgslug = params.params.orgslug const orgslug = params.params.orgslug
const [name, setName] = React.useState('') const [name, setName] = React.useState('')
const [description, setDescription] = React.useState('') const [description, setDescription] = React.useState('')
const [selectedCourses, setSelectedCourses] = React.useState([]) as any const [selectedCourses, setSelectedCourses] = React.useState([]) as any
const [isSubmitting, setIsSubmitting] = useState(false)
const router = useRouter() const router = useRouter()
const { data: courses, error: error } = useSWR( const { data: courses, error: error, isLoading } = useSWR(
`${getAPIUrl()}courses/org_slug/${orgslug}/page/1/limit/10`, `${getAPIUrl()}courses/org_slug/${orgslug}/page/1/limit/10`,
(url) => swrFetcher(url, access_token) (url) => swrFetcher(url, access_token)
) )
@ -32,111 +37,189 @@ function NewCollection(params: any) {
} }
const handleDescriptionChange = ( const handleDescriptionChange = (
event: React.ChangeEvent<HTMLInputElement> event: React.ChangeEvent<HTMLTextAreaElement>
) => { ) => {
setDescription(event.target.value) setDescription(event.target.value)
} }
const handleSubmit = async (e: any) => { const handleSubmit = async (e: any) => {
e.preventDefault() e.preventDefault()
const collection = { if (!name.trim()) {
name: name, toast.error('Please enter a collection name')
description: description, return
courses: selectedCourses,
public: isPublic,
org_id: org.id,
} }
await createCollection(collection, session.data?.tokens?.access_token)
await revalidateTags(['collections'], org.slug)
// reload the page
router.refresh()
// wait for 2s before reloading the page if (!description.trim()) {
setTimeout(() => { toast.error('Please enter a description')
return
}
if (selectedCourses.length === 0) {
toast.error('Please select at least one course')
return
}
setIsSubmitting(true)
try {
const collection = {
name: name.trim(),
description: description.trim(),
courses: selectedCourses,
public: isPublic,
org_id: org.id,
}
await createCollection(collection, session.data?.tokens?.access_token)
await revalidateTags(['collections'], org.slug)
toast.success('Collection created successfully!')
router.push(getUriWithOrg(orgslug, '/collections')) router.push(getUriWithOrg(orgslug, '/collections'))
}, 1000) } catch (error) {
toast.error('Failed to create collection. Please try again.')
} finally {
setIsSubmitting(false)
}
}
if (error) {
return (
<div className="flex items-center justify-center h-[60vh]">
<div className="text-red-500">Failed to load courses. Please try again later.</div>
</div>
)
} }
return ( return (
<> <div className="max-w-2xl mx-auto py-12 px-4">
<div className="w-64 m-auto py-20"> <div className="space-y-8">
<div className="font-bold text-lg mb-4">Add new</div> <div>
<h1 className="text-2xl font-bold text-gray-900">Create New Collection</h1>
<p className="mt-2 text-sm text-gray-600">
Group your courses together in a collection to make them easier to find and manage.
</p>
</div>
<input <form onSubmit={handleSubmit} className="space-y-6">
type="text" <div className="space-y-4">
placeholder="Name" <label className="block">
value={name} <span className="text-sm font-medium text-gray-700">Collection Name</span>
onChange={handleNameChange} <input
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" type="text"
/> placeholder="Enter collection name"
value={name}
onChange={handleNameChange}
className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
maxLength={100}
/>
</label>
<select <label className="block">
onChange={handleVisibilityChange} <span className="text-sm font-medium text-gray-700">Visibility</span>
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" <select
defaultValue={isPublic} onChange={handleVisibilityChange}
> className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
<option value="false">Private Collection</option> defaultValue={isPublic}
<option value="true">Public Collection </option>
</select>
{!courses ? (
<p className="text-gray-500">Loading...</p>
) : (
<div className="space-y-4 p-3">
<p>Courses</p>
{courses.map((course: any) => (
<div
key={course.course_uuid}
className="flex items-center space-x-2"
> >
<input <option value="true">Public Collection - Visible to everyone</option>
type="checkbox" <option value="false">Private Collection - Only visible to organization members</option>
id={course.id} </select>
name={course.name} </label>
value={course.id}
onChange={(e) => {
if (e.target.checked) {
setSelectedCourses([...selectedCourses, course.id])
} else {
setSelectedCourses(
selectedCourses.filter(
(course_uuid: any) =>
course_uuid !== course.course_uuid
)
)
}
}}
className="text-blue-500 rounded focus:ring-2 focus:ring-blue-500"
/>
<label <label className="block">
htmlFor={course.course_uuid} <span className="text-sm font-medium text-gray-700">Description</span>
className="text-sm text-gray-700" <textarea
> placeholder="Enter collection description"
{course.name} value={description}
</label> onChange={handleDescriptionChange}
</div> rows={4}
))} className="mt-1 block w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition"
maxLength={500}
/>
</label>
<div className="space-y-2">
<span className="text-sm font-medium text-gray-700">Select Courses</span>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-gray-500" />
</div>
) : courses?.length === 0 ? (
<p className="text-sm text-gray-500 py-4">No courses available. Create some courses first.</p>
) : (
<div className="mt-2 border border-gray-200 rounded-lg bg-gray-50">
<div className="max-h-[400px] overflow-y-auto p-4 space-y-3 scrollbar-thin scrollbar-thumb-gray-300 scrollbar-track-transparent hover:scrollbar-thumb-gray-400">
{courses?.map((course: any) => (
<label
key={course.id}
className="relative flex items-center p-4 bg-white rounded-md hover:bg-gray-50 transition cursor-pointer gap-4"
>
<input
type="checkbox"
id={course.id}
name={course.name}
value={course.id}
onChange={(e) => {
if (e.target.checked) {
setSelectedCourses([...selectedCourses, course.id])
} else {
setSelectedCourses(
selectedCourses.filter((id: any) => id !== course.id)
)
}
}}
className="h-4 w-4 text-blue-500 rounded border-gray-300 focus:ring-blue-500"
/>
<div className="relative w-24 h-16 rounded-md overflow-hidden bg-gray-100 flex-shrink-0">
{course.thumbnail_image ? (
<img
src={getCourseThumbnailMediaDirectory(org.org_uuid, course.course_uuid, course.thumbnail_image)}
alt={course.name}
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ImageIcon className="w-6 h-6 text-gray-400" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-gray-900 truncate">{course.name}</h3>
{course.description && (
<p className="mt-1 text-xs text-gray-500 line-clamp-2">{course.description}</p>
)}
</div>
</label>
))}
</div>
<div className="px-4 py-3 bg-gray-50 border-t border-gray-200">
<p className="text-xs text-gray-500">
Selected courses: {selectedCourses.length}
</p>
</div>
</div>
)}
</div>
</div> </div>
)}
<input <div className="flex items-center justify-end space-x-4">
type="text" <button
placeholder="Description" type="button"
value={description} onClick={() => router.back()}
onChange={handleDescriptionChange} className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition"
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" >
/> Cancel
</button>
<button <button
onClick={handleSubmit} type="submit"
className="px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-black focus:outline-none focus:ring-2 focus:ring-blue-500" disabled={isSubmitting}
> className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-2"
Submit >
</button> {isSubmitting && <Loader2 className="w-4 h-4 animate-spin" />}
<span>{isSubmitting ? 'Creating...' : 'Create Collection'}</span>
</button>
</div>
</form>
</div> </div>
</> </div>
) )
} }

View file

@ -136,7 +136,7 @@ const CollectionsPage = async (params: any) => {
text="Create a collection to add content" text="Create a collection to add content"
/> />
</p> </p>
<div className="mt-4"> <div className="mt-4 flex justify-center">
<AuthenticatedClientElement <AuthenticatedClientElement
checkMethod="roles" checkMethod="roles"
ressourceType="collections" ressourceType="collections"

View file

@ -1,17 +1,52 @@
'use client' 'use client'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { Info } from 'lucide-react' import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import OrgEditGeneral from '@components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral' import OrgEditGeneral from '@components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral'
import OrgEditImages from '@components/Dashboard/Pages/Org/OrgEditImages/OrgEditImages'
import OrgEditSocials from '@components/Dashboard/Pages/Org/OrgEditSocials/OrgEditSocials'
export type OrgParams = { export type OrgParams = {
subpage: string subpage: string
orgslug: string orgslug: string
} }
interface TabItem {
id: string
label: string
icon: LucideIcon
}
const SETTING_TABS: TabItem[] = [
{ id: 'general', label: 'General', icon: TextIcon },
{ id: 'previews', label: 'Images & Previews', icon: ImageIcon },
{ id: 'socials', label: 'Socials', icon: Share2Icon },
]
function TabLink({ tab, isActive, orgslug }: {
tab: TabItem,
isActive: boolean,
orgslug: string
}) {
return (
<Link href={getUriWithOrg(orgslug, '') + `/dash/org/settings/${tab.id}`}>
<div
className={`py-2 w-fit text-center border-black transition-all ease-linear ${
isActive ? 'border-b-4' : 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2.5">
<tab.icon size={16} />
<div>{tab.label}</div>
</div>
</div>
</Link>
)
}
function OrgPage({ params }: { params: OrgParams }) { function OrgPage({ params }: { params: OrgParams }) {
const [H1Label, setH1Label] = React.useState('') const [H1Label, setH1Label] = React.useState('')
const [H2Label, setH2Label] = React.useState('') const [H2Label, setH2Label] = React.useState('')
@ -20,6 +55,12 @@ function OrgPage({ params }: { params: OrgParams }) {
if (params.subpage == 'general') { if (params.subpage == 'general') {
setH1Label('General') setH1Label('General')
setH2Label('Manage your organization settings') setH2Label('Manage your organization settings')
} else if (params.subpage == 'previews') {
setH1Label('Previews')
setH2Label('Manage your organization previews')
} else if (params.subpage == 'socials') {
setH1Label('Socials')
setH2Label('Manage your organization social media links')
} }
} }
@ -29,9 +70,9 @@ function OrgPage({ params }: { params: OrgParams }) {
return ( return (
<div className="h-full w-full bg-[#f8f8f8]"> <div className="h-full w-full bg-[#f8f8f8]">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] shadow-[0px_4px_16px_rgba(0,0,0,0.02)]"> <div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] nice-shadow">
<BreadCrumbs type="org"></BreadCrumbs> <BreadCrumbs type="org"></BreadCrumbs>
<div className="my-2 py-3"> <div className="my-2 py-2">
<div className="w-100 flex flex-col space-y-1"> <div className="w-100 flex flex-col space-y-1">
<div className="pt-3 flex font-bold text-4xl tracking-tighter"> <div className="pt-3 flex font-bold text-4xl tracking-tighter">
{H1Label} {H1Label}
@ -41,25 +82,15 @@ function OrgPage({ params }: { params: OrgParams }) {
</div> </div>
</div> </div>
</div> </div>
<div className="flex space-x-5 font-black text-sm"> <div className="flex space-x-0.5 font-black text-sm">
<Link {SETTING_TABS.map((tab) => (
href={ <TabLink
getUriWithOrg(params.orgslug, '') + `/dash/org/settings/general` key={tab.id}
} tab={tab}
> isActive={params.subpage === tab.id}
<div orgslug={params.orgslug}
className={`py-2 w-fit text-center border-black transition-all ease-linear ${ />
params.subpage.toString() === 'general' ))}
? 'border-b-4'
: 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
<Info size={16} />
<div>General</div>
</div>
</div>
</Link>
</div> </div>
</div> </div>
<div className="h-6"></div> <div className="h-6"></div>
@ -70,6 +101,8 @@ function OrgPage({ params }: { params: OrgParams }) {
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
> >
{params.subpage == 'general' ? <OrgEditGeneral /> : ''} {params.subpage == 'general' ? <OrgEditGeneral /> : ''}
{params.subpage == 'previews' ? <OrgEditImages /> : ''}
{params.subpage == 'socials' ? <OrgEditSocials /> : ''}
</motion.div> </motion.div>
</div> </div>
) )

View file

@ -1,10 +1,10 @@
'use client' 'use client'
import React, { useState, useEffect } from 'react' import React from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import Link from 'next/link' import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { CreditCard, Settings, Repeat, BookOpen, Users, DollarSign, Gem } from 'lucide-react' import { Settings, Users, Gem } from 'lucide-react'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import PaymentsConfigurationPage from '@components/Dashboard/Pages/Payments/PaymentsConfigurationPage' import PaymentsConfigurationPage from '@components/Dashboard/Pages/Payments/PaymentsConfigurationPage'
@ -20,18 +20,37 @@ export type PaymentsParams = {
function PaymentsPage({ params }: { params: PaymentsParams }) { function PaymentsPage({ params }: { params: PaymentsParams }) {
const session = useLHSession() as any const session = useLHSession() as any
const org = useOrg() as any const org = useOrg() as any
const [selectedSubPage, setSelectedSubPage] = useState(params.subpage || 'general') const subpage = params.subpage || 'customers'
const [H1Label, setH1Label] = useState('')
const [H2Label, setH2Label] = useState('')
const isPaymentsEnabled = useFeatureFlag({ const isPaymentsEnabled = useFeatureFlag({
path: ['features', 'payments', 'enabled'], path: ['features', 'payments', 'enabled'],
defaultValue: false defaultValue: false
}) })
useEffect(() => { const getPageTitle = () => {
handleLabels() switch (subpage) {
}, [selectedSubPage]) case 'customers':
return {
h1: 'Customers',
h2: 'View and manage your customer information'
}
case 'paid-products':
return {
h1: 'Paid Products',
h2: 'Manage your paid products and pricing'
}
case 'configuration':
return {
h1: 'Payment Configuration',
h2: 'Set up and manage your payment gateway'
}
default:
return {
h1: 'Payments',
h2: 'Overview of your payment settings and transactions'
}
}
}
if (!isPaymentsEnabled) { if (!isPaymentsEnabled) {
return ( return (
@ -45,66 +64,41 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
) )
} }
function handleLabels() { const { h1, h2 } = getPageTitle()
if (selectedSubPage === 'general') {
setH1Label('Payments')
setH2Label('Overview of your payment settings and transactions')
}
if (selectedSubPage === 'configuration') {
setH1Label('Payment Configuration')
setH2Label('Set up and manage your payment gateway')
}
if (selectedSubPage === 'subscriptions') {
setH1Label('Subscriptions')
setH2Label('Manage your subscription plans')
}
if (selectedSubPage === 'paid-products') {
setH1Label('Paid Products')
setH2Label('Manage your paid products and pricing')
}
if (selectedSubPage === 'customers') {
setH1Label('Customers')
setH2Label('View and manage your customer information')
}
}
return ( return (
<div className="h-screen w-full bg-[#f8f8f8] flex flex-col"> <div className="h-screen w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]"> <div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 nice-shadow">
<BreadCrumbs type="payments" /> <BreadCrumbs type="payments" />
<div className="my-2 py-3"> <div className="my-2 py-2">
<div className="w-100 flex flex-col space-y-1"> <div className="w-100 flex flex-col space-y-1">
<div className="pt-3 flex font-bold text-4xl tracking-tighter"> <div className="pt-3 flex font-bold text-4xl tracking-tighter">
{H1Label} {h1}
</div> </div>
<div className="flex font-medium text-gray-400 text-md"> <div className="flex font-medium text-gray-400 text-md">
{H2Label}{' '} {h2}
</div> </div>
</div> </div>
</div> </div>
<div className="flex space-x-5 font-black text-sm"> <div className="flex space-x-0.5 font-black text-sm">
<TabLink <TabLink
href={getUriWithOrg(params.orgslug, '/dash/payments/customers')} href={getUriWithOrg(params.orgslug, '/dash/payments/customers')}
icon={<Users size={16} />} icon={<Users size={16} />}
label="Customers" label="Customers"
isActive={selectedSubPage === 'customers'} isActive={subpage === 'customers'}
onClick={() => setSelectedSubPage('customers')}
/> />
<TabLink <TabLink
href={getUriWithOrg(params.orgslug, '/dash/payments/paid-products')} href={getUriWithOrg(params.orgslug, '/dash/payments/paid-products')}
icon={<Gem size={16} />} icon={<Gem size={16} />}
label="Products & Subscriptions" label="Products & Subscriptions"
isActive={selectedSubPage === 'paid-products'} isActive={subpage === 'paid-products'}
onClick={() => setSelectedSubPage('paid-products')}
/> />
<TabLink <TabLink
href={getUriWithOrg(params.orgslug, '/dash/payments/configuration')} href={getUriWithOrg(params.orgslug, '/dash/payments/configuration')}
icon={<Settings size={16} />} icon={<Settings size={16} />}
label="Configuration" label="Configuration"
isActive={selectedSubPage === 'configuration'} isActive={subpage === 'configuration'}
onClick={() => setSelectedSubPage('configuration')}
/> />
</div> </div>
</div> </div>
<div className="h-6"></div> <div className="h-6"></div>
@ -115,21 +109,18 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }} transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
> >
{selectedSubPage === 'general' && <div>General</div>} {subpage === 'configuration' && <PaymentsConfigurationPage />}
{selectedSubPage === 'configuration' && <PaymentsConfigurationPage />} {subpage === 'paid-products' && <PaymentsProductPage />}
{selectedSubPage === 'paid-products' && <PaymentsProductPage />} {subpage === 'customers' && <PaymentsCustomersPage />}
{selectedSubPage === 'customers' && <PaymentsCustomersPage />}
</motion.div> </motion.div>
</div> </div>
) )
} }
const TabLink = ({ href, icon, label, isActive, onClick }: { href: string, icon: React.ReactNode, label: string, isActive: boolean, onClick: () => void }) => ( const TabLink = ({ href, icon, label, isActive }: { href: string, icon: React.ReactNode, label: string, isActive: boolean }) => (
<Link href={href}> <Link href={href}>
<div <div
onClick={onClick} className={`py-2 w-fit text-center border-black transition-all ease-linear ${isActive ? 'border-b-4' : 'opacity-50'} cursor-pointer`}
className={`py-2 w-fit text-center border-black transition-all ease-linear ${isActive ? 'border-b-4' : 'opacity-50'
} cursor-pointer`}
> >
<div className="flex items-center space-x-2.5 mx-2"> <div className="flex items-center space-x-2.5 mx-2">
{icon} {icon}

View file

@ -1,106 +1,112 @@
'use client' 'use client'
import React, { useEffect, useState } from 'react' import React, { useState } from 'react'
import { Field, Form, Formik } from 'formik' import { Form, Formik } from 'formik'
import * as Yup from 'yup'
import { import {
updateOrganization, updateOrganization,
uploadOrganizationLogo,
uploadOrganizationThumbnail,
} from '@services/settings/org' } from '@services/settings/org'
import { UploadCloud, Info } from 'lucide-react'
import { revalidateTags } from '@services/utils/ts/requests' import { revalidateTags } from '@services/utils/ts/requests'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getOrgLogoMediaDirectory, getOrgThumbnailMediaDirectory } from '@services/media/media' import { toast } from 'react-hot-toast'
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs" import { Input } from "@components/ui/input"
import { Toaster, toast } from 'react-hot-toast'; import { Textarea } from "@components/ui/textarea"
import { constructAcceptValue } from '@/lib/constants'; import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@components/ui/select"
import { Switch } from "@components/ui/switch"
import { mutate } from 'swr'
import { getAPIUrl } from '@services/config/config'
import Image from 'next/image'
import learnhouseIcon from '@public/learnhouse_logo.png'
const SUPPORTED_FILES = constructAcceptValue(['png', 'jpg']) const ORG_LABELS = [
{ value: 'languages', label: '🌐 Languages' },
{ value: 'business', label: '💰 Business' },
{ value: 'ecommerce', label: '🛍 E-commerce' },
{ value: 'gaming', label: '🎮 Gaming' },
{ value: 'music', label: '🎸 Music' },
{ value: 'sports', label: '⚽ Sports' },
{ value: 'cars', label: '🚗 Cars' },
{ value: 'sales_marketing', label: '🚀 Sales & Marketing' },
{ value: 'tech', label: '💻 Tech' },
{ value: 'photo_video', label: '📸 Photo & Video' },
{ value: 'pets', label: '🐕 Pets' },
{ value: 'personal_development', label: '📚 Personal Development' },
{ value: 'real_estate', label: '🏠 Real Estate' },
{ value: 'beauty_fashion', label: '👠 Beauty & Fashion' },
{ value: 'travel', label: '✈️ Travel' },
{ value: 'productivity', label: '⏳ Productivity' },
{ value: 'health_fitness', label: '🍎 Health & Fitness' },
{ value: 'finance', label: '📈 Finance' },
{ value: 'arts_crafts', label: '🎨 Arts & Crafts' },
{ value: 'education', label: '📚 Education' },
{ value: 'stem', label: '🔬 STEM' },
{ value: 'humanities', label: '📖 Humanities' },
{ value: 'professional_skills', label: '💼 Professional Skills' },
{ value: 'digital_skills', label: '💻 Digital Skills' },
{ value: 'creative_arts', label: '🎨 Creative Arts' },
{ value: 'social_sciences', label: '🌍 Social Sciences' },
{ value: 'test_prep', label: '✍️ Test Preparation' },
{ value: 'vocational', label: '🔧 Vocational Training' },
{ value: 'early_education', label: '🎯 Early Education' },
] as const
const validationSchema = Yup.object().shape({
name: Yup.string()
.required('Name is required')
.max(60, 'Organization name must be 60 characters or less'),
description: Yup.string()
.required('Short description is required')
.max(100, 'Short description must be 100 characters or less'),
about: Yup.string()
.optional()
.max(400, 'About text must be 400 characters or less'),
label: Yup.string().required('Organization label is required'),
explore: Yup.boolean(),
})
interface OrganizationValues { interface OrganizationValues {
name: string name: string
description: string description: string
slug: string about: string
logo: string label: string
email: string explore: boolean
thumbnail: string
} }
function OrgEditGeneral() { const OrgEditGeneral: React.FC = () => {
const router = useRouter() const router = useRouter()
const session = useLHSession() as any const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token const access_token = session?.data?.tokens?.access_token
const org = useOrg() as any const org = useOrg() as any
const [selectedTab, setSelectedTab] = useState<'logo' | 'thumbnail'>('logo');
const [localLogo, setLocalLogo] = useState<string | null>(null);
const [localThumbnail, setLocalThumbnail] = useState<string | null>(null);
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { const initialValues: OrganizationValues = {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0]
setLocalLogo(URL.createObjectURL(file))
const loadingToast = toast.loading('Uploading logo...');
try {
await uploadOrganizationLogo(org.id, file, access_token)
await new Promise((r) => setTimeout(r, 1500))
toast.success('Logo Updated', { id: loadingToast });
router.refresh()
} catch (err) {
toast.error('Failed to upload logo', { id: loadingToast });
}
}
}
const handleThumbnailChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0];
setLocalThumbnail(URL.createObjectURL(file));
const loadingToast = toast.loading('Uploading thumbnail...');
try {
await uploadOrganizationThumbnail(org.id, file, access_token);
await new Promise((r) => setTimeout(r, 1500));
toast.success('Thumbnail Updated', { id: loadingToast });
router.refresh()
} catch (err) {
toast.error('Failed to upload thumbnail', { id: loadingToast });
}
}
};
const handleImageButtonClick = (inputId: string) => (event: React.MouseEvent) => {
event.preventDefault(); // Prevent form submission
document.getElementById(inputId)?.click();
};
let orgValues: OrganizationValues = {
name: org?.name, name: org?.name,
description: org?.description, description: org?.description || '',
slug: org?.slug, about: org?.about || '',
logo: org?.logo, label: org?.label || '',
email: org?.email, explore: org?.explore ?? true,
thumbnail: org?.thumbnail,
} }
const updateOrg = async (values: OrganizationValues) => { const updateOrg = async (values: OrganizationValues) => {
const loadingToast = toast.loading('Updating organization...'); const loadingToast = toast.loading('Updating organization...')
try { try {
await updateOrganization(org.id, values, access_token) await updateOrganization(org.id, values, access_token)
await revalidateTags(['organizations'], org.slug) await revalidateTags(['organizations'], org.slug)
toast.success('Organization Updated', { id: loadingToast }); mutate(`${getAPIUrl()}orgs/slug/${org.slug}`)
toast.success('Organization Updated', { id: loadingToast })
} catch (err) { } catch (err) {
toast.error('Failed to update organization', { id: loadingToast }); toast.error('Failed to update organization', { id: loadingToast })
} }
} }
useEffect(() => {}, [org])
return ( return (
<div className="sm:ml-10 sm:mr-10 ml-0 mr-0 mx-auto bg-white rounded-xl shadow-sm px-6 py-5 sm:mb-0 mb-16"> <div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow ">
<Toaster />
<Formik <Formik
enableReinitialize enableReinitialize
initialValues={orgValues} initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => { onSubmit={(values, { setSubmitting }) => {
setTimeout(() => { setTimeout(() => {
setSubmitting(false) setSubmitting(false)
@ -108,129 +114,145 @@ function OrgEditGeneral() {
}, 400) }, 400)
}} }}
> >
{({ isSubmitting }) => ( {({ isSubmitting, values, handleChange, errors, touched, setFieldValue }) => (
<Form> <Form>
<div className="flex flex-col lg:flex-row lg:space-x-8"> <div className="flex flex-col gap-0">
<div className="w-full lg:w-1/2 mb-8 lg:mb-0"> <div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md">
<label className="block mb-2 font-bold" htmlFor="name"> <h1 className="font-bold text-xl text-gray-800">
Name Organization Settings
</label> </h1>
<Field <h2 className="text-gray-500 text-md">
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" Manage your organization's profile and settings
type="text" </h2>
name="name"
/>
<label className="block mb-2 font-bold" htmlFor="description">
Description
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="text"
name="description"
/>
<label className="block mb-2 font-bold" htmlFor="slug">
Slug
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg bg-gray-200 cursor-not-allowed"
disabled
type="text"
name="slug"
/>
<label className="block mb-2 font-bold" htmlFor="email">
Email
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="email"
name="email"
/>
<button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-black"
>
Submit
</button>
</div> </div>
<div className="w-full lg:w-1/2"> <div className="flex flex-col lg:flex-row lg:space-x-8 mt-0 mx-5 my-5">
<Tabs defaultValue="logo" className="w-full"> <div className="w-full space-y-6">
<TabsList className="grid w-full grid-cols-2 mb-6 sm:mb-10"> <div className="space-y-4">
<TabsTrigger value="logo">Logo</TabsTrigger> <div>
<TabsTrigger value="thumbnail">Thumbnail</TabsTrigger> <Label htmlFor="name">
</TabsList> Organization Name
<TabsContent value="logo"> <span className="text-gray-500 text-sm ml-2">
<div className="flex flex-col space-y-3"> ({60 - (values.name?.length || 0)} characters left)
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow mx-4 sm:mx-10"> </span>
<div className="flex flex-col justify-center items-center mt-6 sm:mt-10"> </Label>
<div <Input
className="w-[150px] sm:w-[200px] h-[75px] sm:h-[100px] bg-contain bg-no-repeat bg-center rounded-lg nice-shadow bg-white" id="name"
style={{ backgroundImage: `url(${localLogo || getOrgLogoMediaDirectory(org?.org_uuid, org?.logo_image)})` }} name="name"
/> value={values.name}
</div> onChange={handleChange}
<div className="flex justify-center items-center"> placeholder="Organization Name"
<input maxLength={60}
type="file" />
id="fileInput" {touched.name && errors.name && (
accept={SUPPORTED_FILES} <p className="text-red-500 text-sm mt-1">{errors.name}</p>
style={{ display: 'none' }} )}
onChange={handleFileChange}
/>
<button
type="button"
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 py-2 mt-4 flex"
onClick={handleImageButtonClick('fileInput')}
>
<UploadCloud size={16} className="mr-2" />
<span>Change Logo</span>
</button>
</div>
</div>
<div className="flex text-xs space-x-2 items-center text-gray-500 justify-center">
<Info size={13} />
<p>Accepts PNG, JPG</p>
</div>
</div> </div>
</TabsContent>
<TabsContent value="thumbnail"> <div>
<div className="flex flex-col space-y-3"> <Label htmlFor="description">
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow mx-4 sm:mx-10"> Short Description
<div className="flex flex-col justify-center items-center mt-6 sm:mt-10"> <span className="text-gray-500 text-sm ml-2">
<div ({100 - (values.description?.length || 0)} characters left)
className="w-[150px] sm:w-[200px] h-[75px] sm:h-[100px] bg-contain bg-no-repeat bg-center rounded-lg nice-shadow bg-white" </span>
style={{ backgroundImage: `url(${localThumbnail || getOrgThumbnailMediaDirectory(org?.org_uuid, org?.thumbnail_image)})` }} </Label>
/> <Input
</div> id="description"
<div className="flex justify-center items-center"> name="description"
<input value={values.description}
type="file" onChange={handleChange}
accept={SUPPORTED_FILES} placeholder="Brief description of your organization"
id="thumbnailInput" maxLength={100}
style={{ display: 'none' }} />
onChange={handleThumbnailChange} {touched.description && errors.description && (
/> <p className="text-red-500 text-sm mt-1">{errors.description}</p>
<button )}
type="button"
className="font-bold antialiased items-center text-gray text-sm rounded-md px-4 py-2 mt-4 flex"
onClick={handleImageButtonClick('thumbnailInput')}
>
<UploadCloud size={16} className="mr-2" />
<span>Change Thumbnail</span>
</button>
</div>
</div>
<div className="flex text-xs space-x-2 items-center text-gray-500 justify-center">
<Info size={13} />
<p>Accepts PNG, JPG</p>
</div>
</div> </div>
</TabsContent>
</Tabs> <div>
<Label htmlFor="label">Organization Label</Label>
<Select
value={values.label}
onValueChange={(value) => setFieldValue('label', value)}
>
<SelectTrigger>
<SelectValue placeholder="Select organization label" />
</SelectTrigger>
<SelectContent>
{ORG_LABELS.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
{touched.label && errors.label && (
<p className="text-red-500 text-sm mt-1">{errors.label}</p>
)}
</div>
<div>
<Label htmlFor="about">
About Organization
<span className="text-gray-500 text-sm ml-2">
({400 - (values.about?.length || 0)} characters left)
</span>
</Label>
<Textarea
id="about"
name="about"
value={values.about}
onChange={handleChange}
placeholder="Detailed description of your organization"
className="min-h-[150px]"
maxLength={400}
/>
{touched.about && errors.about && (
<p className="text-red-500 text-sm mt-1">{errors.about}</p>
)}
</div>
<div className="flex items-center justify-between space-x-2 mt-6 bg-gray-50/50 p-4 rounded-lg nice-shadow">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Image
quality={100}
width={120}
src={learnhouseIcon}
alt="LearnHouse"
className="rounded-lg"
/>
<span className="px-2 py-1 mt-1 bg-black rounded-md text-[10px] font-semibold text-white">
EXPLORE
</span>
</div>
<div className="space-y-0.5">
<Label className="text-base">Showcase in LearnHouse Explore</Label>
<p className="text-sm text-gray-500">
Share your organization's courses and content with the LearnHouse community.
Enable this to help learners discover your valuable educational resources.
</p>
</div>
</div>
<Switch
name="explore"
checked={values.explore}
onCheckedChange={(checked) => setFieldValue('explore', checked)}
/>
</div>
</div>
</div>
</div>
<div className="flex flex-row-reverse mt-0 mx-5 mb-5">
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div> </div>
</div> </div>
</Form> </Form>

View file

@ -0,0 +1,735 @@
'use client'
import React, { useState } from 'react'
import { UploadCloud, Info, Plus, X, Video, GripVertical, Image, Layout, Images, StarIcon, ImageIcon } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getOrgLogoMediaDirectory, getOrgPreviewMediaDirectory, getOrgThumbnailMediaDirectory } from '@services/media/media'
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs"
import { toast } from 'react-hot-toast'
import { constructAcceptValue } from '@/lib/constants'
import { uploadOrganizationLogo, uploadOrganizationThumbnail, uploadOrganizationPreview, updateOrganization } from '@services/settings/org'
import { cn } from '@/lib/utils'
import { Input } from "@components/ui/input"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@components/ui/dialog"
import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import { SiLoom, SiYoutube } from '@icons-pack/react-simple-icons'
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'
const SUPPORTED_FILES = constructAcceptValue(['png', 'jpg'])
type Preview = {
id: string;
url: string;
type: 'image' | 'youtube' | 'loom';
filename?: string;
thumbnailUrl?: string;
order: number;
};
// Update the height constant
const PREVIEW_HEIGHT = 'h-28' // Reduced height
// Add this type for the video service selection
type VideoService = 'youtube' | 'loom' | null;
// Add this constant for consistent sizing
const DIALOG_ICON_SIZE = 'w-16 h-16'
// Add this constant at the top with other constants
const ADD_PREVIEW_OPTIONS = [
{
id: 'image',
title: 'Upload Images',
description: 'PNG, JPG (max 5MB)',
icon: UploadCloud,
color: 'blue',
onClick: () => document.getElementById('previewInput')?.click()
},
{
id: 'youtube',
title: 'YouTube',
description: 'Add YouTube video',
icon: SiYoutube,
color: 'red',
onClick: (setSelectedService: Function) => setSelectedService('youtube')
},
{
id: 'loom',
title: 'Loom',
description: 'Add Loom video',
icon: SiLoom,
color: 'blue',
onClick: (setSelectedService: Function) => setSelectedService('loom')
}
] as const;
export default function OrgEditImages() {
const router = useRouter()
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const org = useOrg() as any
const [localLogo, setLocalLogo] = useState<string | null>(null)
const [localThumbnail, setLocalThumbnail] = useState<string | null>(null)
const [isLogoUploading, setIsLogoUploading] = useState(false)
const [isThumbnailUploading, setIsThumbnailUploading] = useState(false)
const [previews, setPreviews] = useState<Preview[]>(() => {
// Initialize with image previews
const imagePreviews = (org?.previews?.images || [])
.filter((item: any) => item?.filename) // Filter out empty filenames
.map((item: any, index: number) => ({
id: item.filename,
url: getOrgThumbnailMediaDirectory(org?.org_uuid, item.filename),
filename: item.filename,
type: 'image' as const,
order: item.order ?? index // Use existing order or fallback to index
}));
// Initialize with video previews
const videoPreviews = (org?.previews?.videos || [])
.filter((video: any) => video && video.id)
.map((video: any, index: number) => ({
id: video.id,
url: video.url,
type: video.type as 'youtube' | 'loom',
thumbnailUrl: video.type === 'youtube'
? `https://img.youtube.com/vi/${video.id}/maxresdefault.jpg`
: '',
filename: '',
order: video.order ?? (imagePreviews.length + index) // Use existing order or fallback to index after images
}));
const allPreviews = [...imagePreviews, ...videoPreviews];
return allPreviews.sort((a, b) => a.order - b.order);
});
const [isPreviewUploading, setIsPreviewUploading] = useState(false)
const [videoUrl, setVideoUrl] = useState('')
const [videoDialogOpen, setVideoDialogOpen] = useState(false)
const [selectedService, setSelectedService] = useState<VideoService>(null)
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0]
setLocalLogo(URL.createObjectURL(file))
setIsLogoUploading(true)
const loadingToast = toast.loading('Uploading logo...')
try {
await uploadOrganizationLogo(org.id, file, access_token)
await new Promise((r) => setTimeout(r, 1500))
toast.success('Logo Updated', { id: loadingToast })
router.refresh()
} catch (err) {
toast.error('Failed to upload logo', { id: loadingToast })
} finally {
setIsLogoUploading(false)
}
}
}
const handleThumbnailChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const file = event.target.files[0]
setLocalThumbnail(URL.createObjectURL(file))
setIsThumbnailUploading(true)
const loadingToast = toast.loading('Uploading thumbnail...')
try {
await uploadOrganizationThumbnail(org.id, file, access_token)
await new Promise((r) => setTimeout(r, 1500))
toast.success('Thumbnail Updated', { id: loadingToast })
router.refresh()
} catch (err) {
toast.error('Failed to upload thumbnail', { id: loadingToast })
} finally {
setIsThumbnailUploading(false)
}
}
}
const handleImageButtonClick = (inputId: string) => (event: React.MouseEvent) => {
event.preventDefault()
document.getElementById(inputId)?.click()
}
const handlePreviewUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) {
const files = Array.from(event.target.files)
const remainingSlots = 4 - previews.length
if (files.length > remainingSlots) {
toast.error(`You can only upload ${remainingSlots} more preview${remainingSlots === 1 ? '' : 's'}`)
return
}
setIsPreviewUploading(true)
const loadingToast = toast.loading(`Uploading ${files.length} preview${files.length === 1 ? '' : 's'}...`)
try {
const uploadPromises = files.map(async (file) => {
const response = await uploadOrganizationPreview(org.id, file, access_token)
return {
id: response.name_in_disk,
url: URL.createObjectURL(file),
filename: response.name_in_disk,
type: 'image' as const,
order: previews.length // Add new items at the end
}
})
const newPreviews = await Promise.all(uploadPromises)
const updatedPreviews = [...previews, ...newPreviews]
await updateOrganization(org.id, {
previews: {
images: updatedPreviews
.filter(p => p.type === 'image')
.map(p => ({
filename: p.filename,
order: p.order
})),
videos: updatedPreviews
.filter(p => p.type === 'youtube' || p.type === 'loom')
.map(p => ({
type: p.type,
url: p.url,
id: p.id,
order: p.order
}))
}
}, access_token)
setPreviews(updatedPreviews)
toast.success(`${files.length} preview${files.length === 1 ? '' : 's'} added`, { id: loadingToast })
router.refresh()
} catch (err) {
toast.error('Failed to upload previews', { id: loadingToast })
} finally {
setIsPreviewUploading(false)
}
}
}
const removePreview = async (id: string) => {
const loadingToast = toast.loading('Removing preview...')
try {
const updatedPreviews = previews.filter(p => p.id !== id)
const updatedPreviewFilenames = updatedPreviews.map(p => p.filename)
await updateOrganization(org.id, {
previews: {
images: updatedPreviewFilenames
}
}, access_token)
setPreviews(updatedPreviews)
toast.success('Preview removed', { id: loadingToast })
router.refresh()
} catch (err) {
toast.error('Failed to remove preview', { id: loadingToast })
}
}
const extractVideoId = (url: string, type: 'youtube' | 'loom'): string | null => {
if (type === 'youtube') {
const regex = /(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})/
const match = url.match(regex)
return match ? match[1] : null
} else if (type === 'loom') {
const regex = /(?:loom\.com\/(?:share|embed)\/)([a-zA-Z0-9]+)/
const match = url.match(regex)
return match ? match[1] : null
}
return null
}
const handleVideoSubmit = async (type: 'youtube' | 'loom') => {
const videoId = extractVideoId(videoUrl, type);
if (!videoId) {
toast.error(`Invalid ${type} URL`);
return;
}
// Check if video already exists
if (previews.some(preview => preview.id === videoId)) {
toast.error('This video has already been added');
return;
}
const loadingToast = toast.loading('Adding video preview...');
try {
const thumbnailUrl = type === 'youtube'
? `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`
: '';
const newPreview: Preview = {
id: videoId,
url: videoUrl,
type,
thumbnailUrl,
filename: '',
order: previews.length // Add new items at the end
};
const updatedPreviews = [...previews, newPreview];
await updateOrganization(org.id, {
previews: {
images: updatedPreviews
.filter(p => p.type === 'image')
.map(p => ({
filename: p.filename,
order: p.order
})),
videos: updatedPreviews
.filter(p => p.type === 'youtube' || p.type === 'loom')
.map(p => ({
type: p.type,
url: p.url,
id: p.id,
order: p.order
}))
}
}, access_token);
setPreviews(updatedPreviews);
setVideoUrl('');
setVideoDialogOpen(false);
toast.success('Video preview added', { id: loadingToast });
router.refresh();
} catch (err) {
toast.error('Failed to add video preview', { id: loadingToast });
}
};
const handleDragEnd = async (result: DropResult) => {
if (!result.destination) return;
const items = Array.from(previews);
const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);
// Update order numbers
const reorderedItems = items.map((item, index) => ({
...item,
order: index
}));
setPreviews(reorderedItems);
// Update the order in the backend
const loadingToast = toast.loading('Updating preview order...');
try {
await updateOrganization(org.id, {
previews: {
images: reorderedItems
.filter(p => p.type === 'image')
.map(p => ({
filename: p.filename,
order: p.order
})),
videos: reorderedItems
.filter(p => p.type === 'youtube' || p.type === 'loom')
.map(p => ({
type: p.type,
url: p.url,
id: p.id,
order: p.order
}))
}
}, access_token);
toast.success('Preview order updated', { id: loadingToast });
router.refresh();
} catch (err) {
toast.error('Failed to update preview order', { id: loadingToast });
setPreviews(previews);
}
};
// Add function to reset video dialog state
const resetVideoDialog = () => {
setSelectedService(null)
setVideoUrl('')
}
return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow px-3 py-3 sm:mb-0 mb-16">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mb-2 rounded-md">
<h1 className="font-bold text-xl text-gray-800">
Images & Previews
</h1>
<h2 className="text-gray-500 text-md">
Manage your organization's logo, thumbnail, and preview images
</h2>
</div>
<Tabs defaultValue="logo" className="w-full">
<TabsList className="grid w-full grid-cols-3 p-1 bg-gray-100 rounded-lg">
<TabsTrigger
value="logo"
className="data-[state=active]:bg-white data-[state=active]:shadow-sm transition-all flex items-center space-x-2"
>
<StarIcon size={16} />
<span>Logo</span>
</TabsTrigger>
<TabsTrigger
value="thumbnail"
className="data-[state=active]:bg-white data-[state=active]:shadow-sm transition-all flex items-center space-x-2"
>
<ImageIcon size={16} />
<span>Thumbnail</span>
</TabsTrigger>
<TabsTrigger
value="previews"
className="data-[state=active]:bg-white data-[state=active]:shadow-sm transition-all flex items-center space-x-2"
>
<Images size={16} />
<span>Previews</span>
</TabsTrigger>
</TabsList>
<TabsContent value="logo" className="mt-2">
<div className="flex flex-col space-y-5 w-full">
<div className="w-full bg-gradient-to-b from-gray-50 to-white rounded-xl transition-all duration-300 py-8">
<div className="flex flex-col justify-center items-center space-y-8">
<div className="relative group">
<div
className={cn(
"w-[200px] sm:w-[250px] h-[100px] sm:h-[125px] bg-contain bg-no-repeat bg-center rounded-lg shadow-md bg-white",
"border-2 border-gray-100 hover:border-blue-200 transition-all duration-300",
isLogoUploading && "opacity-50"
)}
style={{ backgroundImage: `url(${localLogo || getOrgLogoMediaDirectory(org?.org_uuid, org?.logo_image)})` }}
/>
</div>
<div className="flex flex-col items-center space-y-4">
<input
type="file"
id="fileInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={handleFileChange}
/>
<button
type="button"
disabled={isLogoUploading}
className={cn(
"font-medium text-sm px-6 py-2.5 rounded-full",
"bg-gradient-to-r from-blue-500 to-blue-600 text-white",
"hover:from-blue-600 hover:to-blue-700",
"shadow-sm hover:shadow transition-all duration-300",
"flex items-center space-x-2",
isLogoUploading && "opacity-75 cursor-not-allowed"
)}
onClick={handleImageButtonClick('fileInput')}
>
<UploadCloud size={18} className={cn("", isLogoUploading && "animate-bounce")} />
<span>{isLogoUploading ? 'Uploading...' : 'Upload New Logo'}</span>
</button>
<div className="flex flex-col text-xs space-y-2 items-center text-gray-500">
<div className="flex items-center space-x-2 bg-blue-50 text-blue-700 px-3 py-1.5 rounded-full">
<Info size={14} />
<p className="font-medium">Accepts PNG, JPG (max 5MB)</p>
</div>
<p className="text-gray-400">Recommended size: 200x100 pixels</p>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="thumbnail" className="mt-2">
<div className="flex flex-col space-y-5 w-full">
<div className="w-full bg-gradient-to-b from-gray-50 to-white rounded-xl transition-all duration-300 py-8">
<div className="flex flex-col justify-center items-center space-y-8">
<div className="relative group">
<div
className={cn(
"w-[200px] sm:w-[250px] h-[100px] sm:h-[125px] bg-contain bg-no-repeat bg-center rounded-lg shadow-md bg-white",
"border-2 border-gray-100 hover:border-purple-200 transition-all duration-300",
isThumbnailUploading && "opacity-50"
)}
style={{ backgroundImage: `url(${localThumbnail || getOrgThumbnailMediaDirectory(org?.org_uuid, org?.thumbnail_image)})` }}
/>
</div>
<div className="flex flex-col items-center space-y-4">
<input
type="file"
id="thumbnailInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={handleThumbnailChange}
/>
<button
type="button"
disabled={isThumbnailUploading}
className={cn(
"font-medium text-sm px-6 py-2.5 rounded-full",
"bg-gradient-to-r from-purple-500 to-purple-600 text-white",
"hover:from-purple-600 hover:to-purple-700",
"shadow-sm hover:shadow transition-all duration-300",
"flex items-center space-x-2",
isThumbnailUploading && "opacity-75 cursor-not-allowed"
)}
onClick={handleImageButtonClick('thumbnailInput')}
>
<UploadCloud size={18} className={cn("", isThumbnailUploading && "animate-bounce")} />
<span>{isThumbnailUploading ? 'Uploading...' : 'Upload New Thumbnail'}</span>
</button>
<div className="flex flex-col text-xs space-y-2 items-center text-gray-500">
<div className="flex items-center space-x-2 bg-purple-50 text-purple-700 px-3 py-1.5 rounded-full">
<Info size={14} />
<p className="font-medium">Accepts PNG, JPG (max 5MB)</p>
</div>
<p className="text-gray-400">Recommended size: 200x100 pixels</p>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
<TabsContent value="previews" className="mt-4">
<div className="flex flex-col space-y-5 w-full">
<div className="w-full bg-gradient-to-b from-gray-50 to-white rounded-xl transition-all duration-300 py-6">
<div className="flex flex-col justify-center items-center space-y-6">
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="previews" direction="horizontal">
{(provided) => (
<div
className={cn(
"flex gap-4 w-full max-w-5xl p-4 overflow-x-auto pb-6",
previews.length === 0 && "justify-center"
)}
{...provided.droppableProps}
ref={provided.innerRef}
>
{previews.map((preview, index) => (
<Draggable
key={preview.id}
draggableId={preview.id}
index={index}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={cn(
"relative group flex-shrink-0",
"w-48",
snapshot.isDragging ? "scale-105 z-50" : "hover:scale-102",
)}
>
<button
onClick={() => removePreview(preview.id)}
className={cn(
"absolute -top-2 -right-2 bg-red-500 hover:bg-red-600 text-white rounded-full p-1.5",
"opacity-0 group-hover:opacity-100 z-10 shadow-sm",
"transition-opacity duration-200"
)}
>
<X size={14} />
</button>
<div
{...provided.dragHandleProps}
className={cn(
"absolute -top-2 -left-2 bg-gray-600 hover:bg-gray-700 text-white rounded-full p-1.5",
"opacity-0 group-hover:opacity-100 cursor-grab active:cursor-grabbing z-10 shadow-sm",
"transition-opacity duration-200"
)}
>
<GripVertical size={14} />
</div>
{preview.type === 'image' ? (
<div
className={cn(
`w-full ${PREVIEW_HEIGHT} bg-contain bg-no-repeat bg-center rounded-xl bg-white`,
"border border-gray-200 hover:border-gray-300",
"transition-colors duration-200",
snapshot.isDragging ? "shadow-lg" : "shadow-sm hover:shadow-md"
)}
style={{
backgroundImage: `url(${getOrgPreviewMediaDirectory(org?.org_uuid, preview.id)})`,
}}
/>
) : (
<div className={cn(
`w-full ${PREVIEW_HEIGHT} relative rounded-xl overflow-hidden`,
"border border-gray-200 hover:border-gray-300 transition-colors duration-200",
snapshot.isDragging ? "shadow-lg" : "shadow-sm hover:shadow-md"
)}>
<div
className="absolute inset-0 bg-cover bg-center"
style={{ backgroundImage: `url(${preview.thumbnailUrl})` }}
/>
<div className="absolute inset-0 bg-black bg-opacity-40 backdrop-blur-[2px] flex items-center justify-center">
{preview.type === 'youtube' ? (
<SiYoutube className="w-10 h-10 text-red-500" />
) : (
<SiLoom className="w-10 h-10 text-blue-500" />
)}
</div>
</div>
)}
</div>
)}
</Draggable>
))}
{provided.placeholder}
{previews.length < 4 && (
<div className={cn(
"flex-shrink-0 w-48",
previews.length === 0 && "m-0"
)}>
<Dialog open={videoDialogOpen} onOpenChange={(open) => {
setVideoDialogOpen(open);
if (!open) resetVideoDialog();
}}>
<DialogTrigger asChild>
<button
className={cn(
`w-full ${PREVIEW_HEIGHT}`,
"border-2 border-dashed border-gray-200 rounded-xl",
"hover:border-blue-300 hover:bg-blue-50/50 transition-all duration-200",
"flex flex-col items-center justify-center space-y-2 group"
)}
>
<div className="bg-blue-50 rounded-full p-2 group-hover:bg-blue-100 transition-colors duration-200">
<Plus size={20} className="text-blue-500" />
</div>
<span className="text-sm font-medium text-gray-600">Add Preview</span>
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Add Preview</DialogTitle>
</DialogHeader>
<div className={cn(
"p-6",
selectedService ? "space-y-4" : "grid grid-cols-3 gap-6"
)}>
{!selectedService ? (
<>
{ADD_PREVIEW_OPTIONS.map((option) => (
<button
key={option.id}
onClick={() => option.id === 'image'
? option.onClick()
: option.onClick(setSelectedService)
}
className={cn(
"w-full aspect-square rounded-2xl border-2 border-dashed",
`hover:border-${option.color}-300 hover:bg-${option.color}-50/50`,
"transition-all duration-200",
"flex flex-col items-center justify-center space-y-4",
option.id === 'image' && isPreviewUploading && "opacity-50 cursor-not-allowed"
)}
>
<div className={cn(
DIALOG_ICON_SIZE,
`rounded-full bg-${option.color}-50`,
"flex items-center justify-center"
)}>
<option.icon className={`w-8 h-8 text-${option.color}-500`} />
</div>
<div className="text-center">
<p className="font-medium text-gray-700">{option.title}</p>
<p className="text-sm text-gray-500 mt-1">{option.description}</p>
</div>
</button>
))}
<input
type="file"
id="previewInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={handlePreviewUpload}
multiple
/>
</>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center",
selectedService === 'youtube' ? "bg-red-50" : "bg-blue-50"
)}>
{selectedService === 'youtube' ? (
<SiYoutube className="w-5 h-5 text-red-500" />
) : (
<SiLoom className="w-5 h-5 text-blue-500" />
)}
</div>
<div>
<h3 className="font-medium text-gray-900">
{selectedService === 'youtube' ? 'Add YouTube Video' : 'Add Loom Video'}
</h3>
<p className="text-sm text-gray-500">
{selectedService === 'youtube'
? 'Paste your YouTube video URL'
: 'Paste your Loom video URL'}
</p>
</div>
</div>
<button
onClick={() => setSelectedService(null)}
className="text-gray-400 hover:text-gray-500 transition-colors"
>
<X size={20} />
</button>
</div>
<div className="space-y-3">
<Input
id="videoUrlInput"
placeholder={selectedService === 'youtube'
? 'https://youtube.com/watch?v=...'
: 'https://www.loom.com/share/...'}
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
className="w-full"
autoFocus
/>
<Button
onClick={() => handleVideoSubmit(selectedService)}
className={cn(
"w-full",
selectedService === 'youtube'
? "bg-red-500 hover:bg-red-600"
: "bg-blue-500 hover:bg-blue-600"
)}
disabled={!videoUrl}
>
Add Video
</Button>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</div>
)}
</div>
)}
</Droppable>
</DragDropContext>
<div className="flex items-center space-x-2 bg-gray-50 text-gray-600 px-4 py-2 rounded-full">
<Info size={14} />
<p className="text-sm">Drag to reorder Maximum 4 previews Supports images & videos</p>
</div>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
)
}

View file

@ -0,0 +1,236 @@
'use client'
import React from 'react'
import { Form, Formik } from 'formik'
import { updateOrganization } from '@services/settings/org'
import { revalidateTags } from '@services/utils/ts/requests'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { toast } from 'react-hot-toast'
import { Input } from "@components/ui/input"
import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import {
SiX,
SiFacebook,
SiInstagram,
SiYoutube
} from '@icons-pack/react-simple-icons'
import { Plus, X as XIcon } from "lucide-react"
import { useRouter } from 'next/navigation'
import { mutate } from 'swr'
import { getAPIUrl } from '@services/config/config'
interface OrganizationValues {
socials: {
twitter?: string
facebook?: string
instagram?: string
linkedin?: string
youtube?: string
}
links: {
[key: string]: string
}
}
export default function OrgEditSocials() {
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const org = useOrg() as any
const router = useRouter()
const initialValues: OrganizationValues = {
socials: org?.socials || {},
links: org?.links || {}
}
const updateOrg = async (values: OrganizationValues) => {
const loadingToast = toast.loading('Updating organization...')
try {
await updateOrganization(org.id, values, access_token)
await revalidateTags(['organizations'], org.slug)
mutate(`${getAPIUrl()}orgs/slug/${org.slug}`)
toast.success('Organization Updated', { id: loadingToast })
} catch (err) {
toast.error('Failed to update organization', { id: loadingToast })
}
}
return (
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow">
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values, { setSubmitting }) => {
setTimeout(() => {
setSubmitting(false)
updateOrg(values)
}, 400)
}}
>
{({ isSubmitting, values, handleChange, setFieldValue }) => (
<Form>
<div className="flex flex-col gap-0">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md">
<h1 className="font-bold text-xl text-gray-800">
Social Links
</h1>
<h2 className="text-gray-500 text-md">
Manage your organization's social media presence
</h2>
</div>
<div className="flex flex-col lg:flex-row lg:space-x-8 mt-0 mx-5 my-5">
<div className="w-full space-y-6">
<div>
<Label className="text-lg font-semibold">Social Links</Label>
<div className="space-y-3 bg-gray-50/50 p-4 rounded-lg nice-shadow mt-2">
<div className="grid gap-3">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 flex items-center justify-center bg-[#1DA1F2]/10 rounded-md">
<SiX size={16} color="#1DA1F2"/>
</div>
<Input
id="socials.twitter"
name="socials.twitter"
value={values.socials.twitter || ''}
onChange={handleChange}
placeholder="Twitter profile URL"
className="h-9 bg-white"
/>
</div>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 flex items-center justify-center bg-[#1877F2]/10 rounded-md">
<SiFacebook size={16} color="#1877F2"/>
</div>
<Input
id="socials.facebook"
name="socials.facebook"
value={values.socials.facebook || ''}
onChange={handleChange}
placeholder="Facebook profile URL"
className="h-9 bg-white"
/>
</div>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 flex items-center justify-center bg-[#E4405F]/10 rounded-md">
<SiInstagram size={16} color="#E4405F"/>
</div>
<Input
id="socials.instagram"
name="socials.instagram"
value={values.socials.instagram || ''}
onChange={handleChange}
placeholder="Instagram profile URL"
className="h-9 bg-white"
/>
</div>
<div className="flex items-center space-x-3">
<div className="w-8 h-8 flex items-center justify-center bg-[#FF0000]/10 rounded-md">
<SiYoutube size={16} color="#FF0000"/>
</div>
<Input
id="socials.youtube"
name="socials.youtube"
value={values.socials.youtube || ''}
onChange={handleChange}
placeholder="YouTube channel URL"
className="h-9 bg-white"
/>
</div>
</div>
</div>
</div>
</div>
<div className="w-full space-y-6">
<div>
<Label className="text-lg font-semibold">Custom Links</Label>
<div className="space-y-3 bg-gray-50/50 p-4 rounded-lg nice-shadow mt-2">
{Object.entries(values.links).map(([linkKey, linkValue], index) => (
<div key={index} className="flex gap-3 items-center">
<div className="w-8 h-8 flex items-center justify-center bg-gray-200/50 rounded-md text-xs font-medium text-gray-600">
{index + 1}
</div>
<div className="flex-1 flex gap-2">
<Input
placeholder="Label"
value={linkKey}
className="h-9 w-1/3 bg-white"
onChange={(e) => {
const newLinks = { ...values.links };
delete newLinks[linkKey];
newLinks[e.target.value] = linkValue;
setFieldValue('links', newLinks);
}}
/>
<Input
placeholder="URL"
value={linkValue}
className="h-9 flex-1 bg-white"
onChange={(e) => {
const newLinks = { ...values.links };
newLinks[linkKey] = e.target.value;
setFieldValue('links', newLinks);
}}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newLinks = { ...values.links };
delete newLinks[linkKey];
setFieldValue('links', newLinks);
}}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
</div>
))}
{Object.keys(values.links).length < 3 && (
<Button
type="button"
variant="outline"
size="sm"
className="mt-2"
onClick={() => {
const newLinks = { ...values.links };
newLinks[`Link ${Object.keys(newLinks).length + 1}`] = '';
setFieldValue('links', newLinks);
}}
>
<Plus className="h-4 w-4 mr-2" />
Add Link
</Button>
)}
<p className="text-xs text-gray-500 mt-2">
Add up to 3 custom links that will appear on your organization's profile
</p>
</div>
</div>
</div>
</div>
<div className="flex flex-row-reverse mt-3 mx-5 mb-5">
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</Form>
)}
</Formik>
</div>
)
}

View file

@ -83,7 +83,7 @@ const PaymentsConfigurationPage: React.FC = () => {
return ( return (
<div> <div>
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4"> <div className="ml-10 mr-10 mx-auto bg-white rounded-xl nice-shadow px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3"> <div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-xl text-gray-800">Payments Configuration</h1> <h1 className="font-bold text-xl text-gray-800">Payments Configuration</h1>
<h2 className="text-gray-500 text-md">Manage your organization payments configuration</h2> <h2 className="text-gray-500 text-md">Manage your organization payments configuration</h2>

View file

@ -141,7 +141,7 @@ function PaymentsCustomersPage() {
if (!customers) return <div>No customer data available</div> if (!customers) return <div>No customer data available</div>
return ( return (
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4"> <div className="ml-10 mr-10 mx-auto bg-white rounded-xl nice-shadow px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3"> <div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-xl text-gray-800">Customers</h1> <h1 className="font-bold text-xl text-gray-800">Customers</h1>
<h2 className="text-gray-500 text-md">View and manage your customer information</h2> <h2 className="text-gray-500 text-md">View and manage your customer information</h2>

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { updateProfile } from '@services/settings/profile' import { updateProfile } from '@services/settings/profile'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { Formik, Form, Field } from 'formik' import { Formik, Form } from 'formik'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { import {
ArrowBigUpDash, ArrowBigUpDash,
@ -9,13 +9,39 @@ import {
FileWarning, FileWarning,
Info, Info,
UploadCloud, UploadCloud,
AlertTriangle,
LogOut
} from 'lucide-react' } from 'lucide-react'
import UserAvatar from '@components/Objects/UserAvatar' import UserAvatar from '@components/Objects/UserAvatar'
import { updateUserAvatar } from '@services/users/users' import { updateUserAvatar } from '@services/users/users'
import { constructAcceptValue } from '@/lib/constants'; import { constructAcceptValue } from '@/lib/constants'
import * as Yup from 'yup'
import { Input } from "@components/ui/input"
import { Textarea } from "@components/ui/textarea"
import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import { toast } from 'react-hot-toast'
import { signOut } from 'next-auth/react'
import { getUriWithoutOrg } from '@services/config/config';
const SUPPORTED_FILES = constructAcceptValue(['image']) const SUPPORTED_FILES = constructAcceptValue(['image'])
const validationSchema = Yup.object().shape({
email: Yup.string().email('Invalid email').required('Email is required'),
username: Yup.string().required('Username is required'),
first_name: Yup.string().required('First name is required'),
last_name: Yup.string().required('Last name is required'),
bio: Yup.string().max(400, 'Bio must be 400 characters or less'),
})
interface FormValues {
username: string;
first_name: string;
last_name: string;
email: string;
bio: string;
}
function UserEditGeneral() { function UserEditGeneral() {
const session = useLHSession() as any; const session = useLHSession() as any;
const access_token = session?.data?.tokens?.access_token; const access_token = session?.data?.tokens?.access_token;
@ -40,116 +66,231 @@ function UserEditGeneral() {
} }
} }
const handleEmailChange = async (newEmail: string) => {
toast.success('Profile Updated Successfully', { duration: 4000 })
// Show message about logging in with new email
toast((t) => (
<div className="flex items-center gap-2">
<span>Please login again with your new email: {newEmail}</span>
</div>
), {
duration: 4000,
icon: '📧'
})
// Wait for 4 seconds before signing out
await new Promise(resolve => setTimeout(resolve, 4000))
signOut({ redirect: true, callbackUrl: getUriWithoutOrg('/') })
}
useEffect(() => { }, [session, session.data]) useEffect(() => { }, [session, session.data])
return ( return (
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-6 py-5 sm:mb-0 mb-16"> <div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow">
{session.data.user && ( {session.data.user && (
<Formik <Formik<FormValues>
enableReinitialize enableReinitialize
initialValues={{ initialValues={{
username: session.data.user.username, username: session.data.user.username,
first_name: session.data.user.first_name, first_name: session.data.user.first_name,
last_name: session.data.user.last_name, last_name: session.data.user.last_name,
email: session.data.user.email, email: session.data.user.email,
bio: session.data.user.bio, bio: session.data.user.bio || '',
}} }}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => { onSubmit={(values, { setSubmitting }) => {
const isEmailChanged = values.email !== session.data.user.email
const loadingToast = toast.loading('Updating profile...')
setTimeout(() => { setTimeout(() => {
setSubmitting(false) setSubmitting(false)
updateProfile(values, session.data.user.id, access_token) updateProfile(values, session.data.user.id, access_token)
.then(() => {
toast.dismiss(loadingToast)
if (isEmailChanged) {
handleEmailChange(values.email)
} else {
toast.success('Profile Updated Successfully')
}
})
.catch(() => {
toast.error('Failed to update profile', { id: loadingToast })
})
}, 400) }, 400)
}} }}
> >
{({ isSubmitting }) => ( {({ isSubmitting, values, handleChange, errors, touched }) => (
<div className="flex flex-col lg:flex-row gap-8"> <Form>
<Form className="flex-1 min-w-0"> <div className="flex flex-col gap-0">
<div className="space-y-4"> <div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md">
{[ <h1 className="font-bold text-xl text-gray-800">
{ label: 'Email', name: 'email', type: 'email' }, Account Settings
{ label: 'Username', name: 'username', type: 'text' }, </h1>
{ label: 'First Name', name: 'first_name', type: 'text' }, <h2 className="text-gray-500 text-md">
{ label: 'Last Name', name: 'last_name', type: 'text' }, Manage your personal information and preferences
{ label: 'Bio', name: 'bio', type: 'text' }, </h2>
].map((field) => (
<div key={field.name}>
<label className="block mb-2 font-bold" htmlFor={field.name}>
{field.label}
</label>
<Field
className="w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type={field.type}
name={field.name}
/>
</div>
))}
</div> </div>
<button
type="submit" <div className="flex flex-col lg:flex-row mt-0 mx-5 my-5 gap-8">
disabled={isSubmitting} {/* Profile Information Section */}
className="mt-6 px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500" <div className="flex-1 min-w-0 space-y-4">
> <div>
Submit <Label htmlFor="email">Email</Label>
</button> <Input
</Form> id="email"
<div className="flex-1 min-w-0"> name="email"
<div className="flex flex-col items-center space-y-4"> type="email"
<label className="font-bold">Avatar</label> value={values.email}
{error && ( onChange={handleChange}
<div className="flex items-center bg-red-200 rounded-md text-red-950 px-4 py-2 text-sm"> placeholder="Your email address"
<FileWarning size={16} className="mr-2" /> />
<span className="font-semibold first-letter:uppercase">{error}</span> {touched.email && errors.email && (
</div> <p className="text-red-500 text-sm mt-1">{errors.email}</p>
)}
{success && (
<div className="flex items-center bg-green-200 rounded-md text-green-950 px-4 py-2 text-sm">
<Check size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{success}</span>
</div>
)}
<div className="w-full max-w-xs bg-gray-50 rounded-xl outline outline-1 outline-gray-200 shadow p-6">
<div className="flex flex-col items-center space-y-4">
{localAvatar ? (
<UserAvatar
border="border-8"
width={100}
avatar_url={URL.createObjectURL(localAvatar)}
/>
) : (
<UserAvatar border="border-8" width={100} />
)} )}
{isLoading ? ( {values.email !== session.data.user.email && (
<div className="font-bold animate-pulse antialiased bg-green-200 text-gray text-sm rounded-md px-4 py-2 flex items-center"> <div className="flex items-center space-x-2 mt-2 text-amber-600 bg-amber-50 p-2 rounded-md">
<ArrowBigUpDash size={16} className="mr-2" /> <AlertTriangle size={16} />
<span>Uploading</span> <span className="text-sm">You will be logged out after changing your email</span>
</div> </div>
) : ( )}
<> </div>
<input
type="file" <div>
id="fileInput" <Label htmlFor="username">Username</Label>
accept={SUPPORTED_FILES} <Input
className="hidden" id="username"
onChange={handleFileChange} name="username"
/> value={values.username}
<button onChange={handleChange}
className="font-bold antialiased text-gray text-sm rounded-md px-4 py-2 flex items-center" placeholder="Your username"
onClick={() => document.getElementById('fileInput')?.click()} />
> {touched.username && errors.username && (
<UploadCloud size={16} className="mr-2" /> <p className="text-red-500 text-sm mt-1">{errors.username}</p>
<span>Change Avatar</span> )}
</button> </div>
</>
<div>
<Label htmlFor="first_name">First Name</Label>
<Input
id="first_name"
name="first_name"
value={values.first_name}
onChange={handleChange}
placeholder="Your first name"
/>
{touched.first_name && errors.first_name && (
<p className="text-red-500 text-sm mt-1">{errors.first_name}</p>
)}
</div>
<div>
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
name="last_name"
value={values.last_name}
onChange={handleChange}
placeholder="Your last name"
/>
{touched.last_name && errors.last_name && (
<p className="text-red-500 text-sm mt-1">{errors.last_name}</p>
)}
</div>
<div>
<Label htmlFor="bio">
Bio
<span className="text-gray-500 text-sm ml-2">
({400 - (values.bio?.length || 0)} characters left)
</span>
</Label>
<Textarea
id="bio"
name="bio"
value={values.bio}
onChange={handleChange}
placeholder="Tell us about yourself"
className="min-h-[150px]"
maxLength={400}
/>
{touched.bio && errors.bio && (
<p className="text-red-500 text-sm mt-1">{errors.bio}</p>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center text-xs text-gray-500">
<Info size={13} className="mr-2" /> {/* Profile Picture Section */}
<p>Recommended size 100x100</p> <div className="lg:w-80 w-full">
<div className="bg-gray-50/50 p-6 rounded-lg nice-shadow h-full">
<div className="flex flex-col items-center space-y-6">
<Label className="font-bold">Profile Picture</Label>
{error && (
<div className="flex items-center bg-red-200 rounded-md text-red-950 px-4 py-2 text-sm">
<FileWarning size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{error}</span>
</div>
)}
{success && (
<div className="flex items-center bg-green-200 rounded-md text-green-950 px-4 py-2 text-sm">
<Check size={16} className="mr-2" />
<span className="font-semibold first-letter:uppercase">{success}</span>
</div>
)}
{localAvatar ? (
<UserAvatar
border="border-8"
width={120}
avatar_url={URL.createObjectURL(localAvatar)}
/>
) : (
<UserAvatar border="border-8" width={120} />
)}
{isLoading ? (
<div className="font-bold animate-pulse antialiased bg-green-200 text-gray text-sm rounded-md px-4 py-2 flex items-center">
<ArrowBigUpDash size={16} className="mr-2" />
<span>Uploading</span>
</div>
) : (
<>
<input
type="file"
id="fileInput"
accept={SUPPORTED_FILES}
className="hidden"
onChange={handleFileChange}
/>
<Button
type="button"
variant="outline"
onClick={() => document.getElementById('fileInput')?.click()}
className="w-full"
>
<UploadCloud size={16} className="mr-2" />
Change Avatar
</Button>
</>
)}
<div className="flex items-center text-xs text-gray-500">
<Info size={13} className="mr-2" />
<p>Recommended size 100x100</p>
</div>
</div>
</div>
</div> </div>
</div> </div>
<div className="flex flex-row-reverse mt-0 mx-5 mb-5">
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div> </div>
</div> </Form>
)} )}
</Formik> </Formik>
)} )}

View file

@ -1,61 +1,134 @@
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { updatePassword } from '@services/settings/password' import { updatePassword } from '@services/settings/password'
import { Formik, Form, Field } from 'formik' import { Formik, Form } from 'formik'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { AlertTriangle } from 'lucide-react'
import { Input } from "@components/ui/input"
import { Button } from "@components/ui/button"
import { Label } from "@components/ui/label"
import { toast } from 'react-hot-toast'
import { signOut } from 'next-auth/react'
import { getUriWithoutOrg } from '@services/config/config'
import * as Yup from 'yup'
const validationSchema = Yup.object().shape({
old_password: Yup.string().required('Current password is required'),
new_password: Yup.string()
.required('New password is required')
.min(8, 'Password must be at least 8 characters'),
})
function UserEditPassword() { function UserEditPassword() {
const session = useLHSession() as any const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token; const access_token = session?.data?.tokens?.access_token;
const updatePasswordUI = async (values: any) => { const updatePasswordUI = async (values: any) => {
let user_id = session.data.user.id const loadingToast = toast.loading('Updating password...')
await updatePassword(user_id, values, access_token) try {
let user_id = session.data.user.id
const response = await updatePassword(user_id, values, access_token)
if (response.success) {
toast.dismiss(loadingToast)
// Show success message and notify about logout
toast.success('Password updated successfully', { duration: 4000 })
toast((t) => (
<div className="flex items-center gap-2">
<span>Please login again with your new password</span>
</div>
), {
duration: 4000,
icon: '🔑'
})
// Wait for 4 seconds before signing out
await new Promise(resolve => setTimeout(resolve, 4000))
signOut({ redirect: true, callbackUrl: getUriWithoutOrg('/') })
} else {
toast.error(response.data.detail || 'Failed to update password', { id: loadingToast })
}
} catch (error: any) {
const errorMessage = error.data?.detail || 'Failed to update password. Please try again.'
toast.error(errorMessage, { id: loadingToast })
console.error('Password update error:', error)
}
} }
useEffect(() => { }, [session]) useEffect(() => { }, [session])
return ( return (
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-6 py-5"> <div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow">
<Formik <div className="flex flex-col">
initialValues={{ old_password: '', new_password: '' }} <div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 mx-3 my-3 rounded-md">
enableReinitialize <h1 className="font-bold text-xl text-gray-800">
onSubmit={(values, { setSubmitting }) => { Change Password
setTimeout(() => { </h1>
setSubmitting(false) <h2 className="text-gray-500 text-md">
updatePasswordUI(values) Update your password to keep your account secure
}, 400) </h2>
}} </div>
>
{({ isSubmitting }) => (
<Form className="max-w-md">
<label className="block mb-2 font-bold" htmlFor="old_password">
Old Password
</label>
<Field
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
type="password"
name="old_password"
/>
<label className="block mb-2 font-bold" htmlFor="new_password"> <div className="px-8 py-6">
New Password <Formik
</label> initialValues={{ old_password: '', new_password: '' }}
<Field validationSchema={validationSchema}
className="w-full px-4 py-2 mb-4 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" onSubmit={(values, { setSubmitting }) => {
type="password" setTimeout(() => {
name="new_password" setSubmitting(false)
/> updatePasswordUI(values)
}, 400)
}}
>
{({ isSubmitting, handleChange, errors, touched }) => (
<Form className="w-full max-w-2xl mx-auto space-y-6">
<div>
<Label htmlFor="old_password">Current Password</Label>
<Input
type="password"
id="old_password"
name="old_password"
onChange={handleChange}
className="mt-1"
/>
{touched.old_password && errors.old_password && (
<p className="text-red-500 text-sm mt-1">{errors.old_password}</p>
)}
</div>
<button <div>
type="submit" <Label htmlFor="new_password">New Password</Label>
disabled={isSubmitting} <Input
className="px-6 py-3 text-white bg-black rounded-lg shadow-md hover:bg-black focus:outline-none focus:ring-2 focus:ring-blue-500" type="password"
> id="new_password"
Submit name="new_password"
</button> onChange={handleChange}
</Form> className="mt-1"
)} />
</Formik> {touched.new_password && errors.new_password && (
<p className="text-red-500 text-sm mt-1">{errors.new_password}</p>
)}
</div>
<div className="flex items-center space-x-2 text-amber-600 bg-amber-50 p-3 rounded-md">
<AlertTriangle size={16} />
<span className="text-sm">You will be logged out after changing your password</span>
</div>
<div className="flex justify-end pt-2">
<Button
type="submit"
disabled={isSubmitting}
className="bg-black text-white hover:bg-black/90"
>
{isSubmitting ? 'Updating...' : 'Update Password'}
</Button>
</div>
</Form>
)}
</Formik>
</div>
</div>
</div> </div>
) )
} }

View file

@ -23,21 +23,21 @@ type ModalParams = {
const Modal = (params: ModalParams) => { const Modal = (params: ModalParams) => {
const getMinHeight = () => { const getMinHeight = () => {
switch (params.minHeight) { switch (params.minHeight) {
case 'sm': return 'min-h-[300px]' case 'sm': return 'min-h-[300px] max-h-[90vh]'
case 'md': return 'min-h-[500px]' case 'md': return 'min-h-[400px] max-h-[90vh]'
case 'lg': return 'min-h-[700px]' case 'lg': return 'min-h-[500px] max-h-[90vh]'
case 'xl': return 'min-h-[900px]' case 'xl': return 'min-h-[600px] max-h-[90vh]'
default: return '' default: return 'max-h-[90vh]'
} }
} }
const getMinWidth = () => { const getMinWidth = () => {
switch (params.minWidth) { switch (params.minWidth) {
case 'sm': return 'min-w-[600px]' case 'sm': return 'w-[95vw] sm:w-[90vw] md:w-[600px]'
case 'md': return 'min-w-[800px]' case 'md': return 'w-[95vw] sm:w-[90vw] md:w-[800px]'
case 'lg': return 'min-w-[1000px]' case 'lg': return 'w-[95vw] sm:w-[90vw] lg:w-[1000px]'
case 'xl': return 'min-w-[1200px]' case 'xl': return 'w-[95vw] sm:w-[90vw] xl:w-[1200px]'
default: return '' default: return 'w-[95vw] sm:w-[90vw]'
} }
} }
@ -47,7 +47,8 @@ const Modal = (params: ModalParams) => {
<DialogTrigger asChild>{params.dialogTrigger}</DialogTrigger> <DialogTrigger asChild>{params.dialogTrigger}</DialogTrigger>
)} )}
<DialogContent className={cn( <DialogContent className={cn(
"overflow-auto", "overflow-auto mx-auto",
"p-4 md:p-6",
getMinHeight(), getMinHeight(),
getMinWidth(), getMinWidth(),
params.customHeight, params.customHeight,

View file

@ -65,6 +65,7 @@ function ActivityIndicators(props: Props) {
key={activity.activity_uuid} key={activity.activity_uuid}
> >
<Link <Link
prefetch={true}
href={ href={
getUriWithOrg(orgslug, '') + getUriWithOrg(orgslug, '') +
`/course/${courseid}/activity/${activity.activity_uuid.replace( `/course/${courseid}/activity/${activity.activity_uuid.replace(

View file

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View file

@ -11,10 +11,10 @@ function getMediaUrl() {
export function getCourseThumbnailMediaDirectory( export function getCourseThumbnailMediaDirectory(
orgUUID: string, orgUUID: string,
courseId: string, courseUUID: string,
fileId: string fileId: string
) { ) {
let uri = `${getMediaUrl()}content/orgs/${orgUUID}/courses/${courseId}/thumbnails/${fileId}` let uri = `${getMediaUrl()}content/orgs/${orgUUID}/courses/${courseUUID}/thumbnails/${fileId}`
return uri return uri
} }
@ -96,3 +96,8 @@ export function getOrgThumbnailMediaDirectory(orgUUID: string, fileId: string) {
let uri = `${getMediaUrl()}content/orgs/${orgUUID}/thumbnails/${fileId}` let uri = `${getMediaUrl()}content/orgs/${orgUUID}/thumbnails/${fileId}`
return uri return uri
} }
export function getOrgPreviewMediaDirectory(orgUUID: string, fileId: string) {
let uri = `${getMediaUrl()}content/orgs/${orgUUID}/previews/${fileId}`
return uri
}

View file

@ -54,3 +54,15 @@ export async function uploadOrganizationThumbnail(
const res = await errorHandling(result) const res = await errorHandling(result)
return res return res
} }
export const uploadOrganizationPreview = async (orgId: string, file: File, access_token: string) => {
const formData = new FormData();
formData.append('preview_file', file);
const result: any = await fetch(
`${getAPIUrl()}orgs/` + orgId + '/preview',
RequestBodyFormWithAuthHeader('PUT', formData, null, access_token)
)
const res = await errorHandling(result)
return res
};

View file

@ -2,6 +2,7 @@ import { getAPIUrl } from '@services/config/config'
import { import {
RequestBodyWithAuthHeader, RequestBodyWithAuthHeader,
errorHandling, errorHandling,
getResponseMetadata,
} from '@services/utils/ts/requests' } from '@services/utils/ts/requests'
/* /*
@ -18,6 +19,6 @@ export async function updatePassword(
`${getAPIUrl()}users/change_password/` + user_id, `${getAPIUrl()}users/change_password/` + user_id,
RequestBodyWithAuthHeader('PUT', data, null, access_token) RequestBodyWithAuthHeader('PUT', data, null, access_token)
) )
const res = await errorHandling(result) const res = await getResponseMetadata(result)
return res return res
} }