diff --git a/apps/api/migrations/versions/87a621284ae4_organizations_new_model.py b/apps/api/migrations/versions/87a621284ae4_organizations_new_model.py
new file mode 100644
index 00000000..62370b4e
--- /dev/null
+++ b/apps/api/migrations/versions/87a621284ae4_organizations_new_model.py
@@ -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 ###
diff --git a/apps/api/src/db/organizations.py b/apps/api/src/db/organizations.py
index f5702103..81dbfea8 100644
--- a/apps/api/src/db/organizations.py
+++ b/apps/api/src/db/organizations.py
@@ -1,6 +1,6 @@
from typing import Optional
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.organization_config import OrganizationConfig
@@ -9,10 +9,16 @@ from src.db.organization_config import OrganizationConfig
class OrganizationBase(SQLModel):
name: str
description: Optional[str]
- slug: str
- email: str
+ about: Optional[str]
+ socials: Optional[dict] = Field(default={}, sa_column=Column(JSON))
+ links: Optional[dict] = Field(default={}, sa_column=Column(JSON))
logo_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):
@@ -26,9 +32,19 @@ class OrganizationWithConfig(BaseModel):
config: OrganizationConfig
-class OrganizationUpdate(OrganizationBase):
- pass
-
+class OrganizationUpdate(SQLModel):
+ 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):
pass
diff --git a/apps/api/src/routers/ee/cloud_internal.py b/apps/api/src/routers/ee/cloud_internal.py
index b4224ddf..8134fd31 100644
--- a/apps/api/src/routers/ee/cloud_internal.py
+++ b/apps/api/src/routers/ee/cloud_internal.py
@@ -1,8 +1,10 @@
import os
+from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session
from src.core.events.database import get_db_session
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
router = APIRouter()
@@ -14,6 +16,49 @@ def check_internal_cloud_key(request: Request):
):
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")
async def update_org_Config(
diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py
index 5c171f62..e6025829 100644
--- a/apps/api/src/routers/orgs.py
+++ b/apps/api/src/routers/orgs.py
@@ -37,6 +37,7 @@ from src.services.orgs.orgs import (
get_orgs_by_user_admin,
update_org,
update_org_logo,
+ update_org_preview,
update_org_signup_mechanism,
update_org_thumbnail,
)
@@ -334,6 +335,25 @@ async def api_update_org_thumbnail(
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}")
async def api_user_orgs(
diff --git a/apps/api/src/services/courses/collections.py b/apps/api/src/services/courses/collections.py
index d54faedf..774e2393 100644
--- a/apps/api/src/services/courses/collections.py
+++ b/apps/api/src/services/courses/collections.py
@@ -47,15 +47,23 @@ async def get_collection(
# get courses in collection
statement_all = (
select(Course)
- .join(CollectionCourse, Course.id == CollectionCourse.course_id)
- .where(CollectionCourse.org_id == collection.org_id)
- .distinct(Course.id)
+ .join(CollectionCourse)
+ .where(
+ CollectionCourse.collection_id == collection.id,
+ CollectionCourse.org_id == collection.org_id
+ )
+ .distinct()
)
statement_public = (
select(Course)
- .join(CollectionCourse, Course.id == CollectionCourse.course_id)
- .where(CollectionCourse.org_id == collection.org_id, Course.public == True)
+ .join(CollectionCourse)
+ .where(
+ CollectionCourse.collection_id == collection.id,
+ CollectionCourse.org_id == collection.org_id,
+ Course.public == True
+ )
+ .distinct()
)
if current_user.user_uuid == "user_anonymous":
@@ -63,7 +71,7 @@ async def get_collection(
else:
statement = statement_all
- courses = db_session.exec(statement).all()
+ courses = list(db_session.exec(statement).all())
collection = CollectionRead(**collection.model_dump(), courses=courses)
@@ -110,10 +118,11 @@ async def create_collection(
# Get courses once again
statement = (
select(Course)
- .join(CollectionCourse, Course.id == CollectionCourse.course_id)
- .distinct(Course.id)
+ .join(CollectionCourse)
+ .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)
@@ -183,12 +192,11 @@ async def update_collection(
# Get courses once again
statement = (
select(Course)
- .join(CollectionCourse, Course.id == CollectionCourse.course_id)
- .where(Course.org_id == collection.org_id)
- .distinct(Course.id)
+ .join(CollectionCourse)
+ .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)
@@ -255,14 +263,22 @@ async def get_collections(
for collection in collections:
statement_all = (
select(Course)
- .join(CollectionCourse, Course.id == CollectionCourse.course_id)
- .where(CollectionCourse.org_id == collection.org_id)
- .distinct(Course.id)
+ .join(CollectionCourse)
+ .where(
+ CollectionCourse.collection_id == collection.id,
+ CollectionCourse.org_id == collection.org_id
+ )
+ .distinct()
)
statement_public = (
select(Course)
- .join(CollectionCourse, Course.id == CollectionCourse.course_id)
- .where(CollectionCourse.org_id == org_id, Course.public == True)
+ .join(CollectionCourse)
+ .where(
+ CollectionCourse.collection_id == collection.id,
+ CollectionCourse.org_id == org_id,
+ Course.public == True
+ )
+ .distinct()
)
if current_user.id == 0:
statement = statement_public
diff --git a/apps/api/src/services/explore/explore.py b/apps/api/src/services/explore/explore.py
new file mode 100644
index 00000000..9cc866e5
--- /dev/null
+++ b/apps/api/src/services/explore/explore.py
@@ -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)
+
\ No newline at end of file
diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py
index 13c084ed..447e26b1 100644
--- a/apps/api/src/services/orgs/orgs.py
+++ b/apps/api/src/services/orgs/orgs.py
@@ -36,7 +36,7 @@ from src.db.organizations import (
)
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(
@@ -174,7 +174,7 @@ async def create_org(
storage=StorageOrgConfig(enabled=True, limit=0),
ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"),
assignments=AssignmentOrgConfig(enabled=True, limit=0),
- payments=PaymentOrgConfig(enabled=True, stripe_key=""),
+ payments=PaymentOrgConfig(enabled=True),
discussions=DiscussionOrgConfig(enabled=True, limit=0),
analytics=AnalyticsOrgConfig(enabled=True, limit=0),
collaboration=CollaborationOrgConfig(enabled=True, limit=0),
@@ -458,6 +458,31 @@ async def update_org_thumbnail(
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(
request: Request,
@@ -675,6 +700,19 @@ async def get_org_join_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 ##
diff --git a/apps/api/src/services/orgs/uploads.py b/apps/api/src/services/orgs/uploads.py
index 813b625d..7c393d53 100644
--- a/apps/api/src/services/orgs/uploads.py
+++ b/apps/api/src/services/orgs/uploads.py
@@ -31,3 +31,18 @@ async def upload_org_thumbnail(thumbnail_file, org_uuid):
)
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
\ No newline at end of file
diff --git a/apps/api/src/services/users/emails.py b/apps/api/src/services/users/emails.py
index 6e20e704..a2e383ff 100644
--- a/apps/api/src/services/users/emails.py
+++ b/apps/api/src/services/users/emails.py
@@ -39,7 +39,9 @@ def send_password_reset_email(
Hello {user.username}
- Click here to reset your password.
+ You have requested to reset your password.
+ Here is your reset code: {generated_reset_code}
+ Click here to reset your password.
""",
diff --git a/apps/web/app/auth/reset/reset.tsx b/apps/web/app/auth/reset/reset.tsx
index c9d16bb9..7451d140 100644
--- a/apps/web/app/auth/reset/reset.tsx
+++ b/apps/web/app/auth/reset/reset.tsx
@@ -11,7 +11,7 @@ import * as Form from '@radix-ui/react-form'
import { getOrgLogoMediaDirectory } from '@services/media/media'
import { AlertTriangle, Info } from 'lucide-react'
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 { useRouter, useSearchParams } from 'next/navigation'
import { useFormik } from 'formik'
@@ -139,9 +139,14 @@ function ResetPasswordClient() {
)}
{message && (
-
-
-
{message}
+
+
+
+ Please login again with your new password
+
)}
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx
index be8cbd01..621ce2fa 100644
--- a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx
+++ b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/new/page.tsx
@@ -7,17 +7,22 @@ import { getAPIUrl, getUriWithOrg } from '@services/config/config'
import { revalidateTags, swrFetcher } from '@services/utils/ts/requests'
import { useOrg } from '@components/Contexts/OrgContext'
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) {
const org = useOrg() as any
- const session = useLHSession() as any;
- const access_token = session?.data?.tokens?.access_token;
+ const session = useLHSession() as any
+ const access_token = session?.data?.tokens?.access_token
const orgslug = params.params.orgslug
const [name, setName] = React.useState('')
const [description, setDescription] = React.useState('')
const [selectedCourses, setSelectedCourses] = React.useState([]) as any
+ const [isSubmitting, setIsSubmitting] = useState(false)
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`,
(url) => swrFetcher(url, access_token)
)
@@ -32,111 +37,189 @@ function NewCollection(params: any) {
}
const handleDescriptionChange = (
- event: React.ChangeEvent
+ event: React.ChangeEvent
) => {
setDescription(event.target.value)
}
const handleSubmit = async (e: any) => {
e.preventDefault()
-
- const collection = {
- name: name,
- description: description,
- courses: selectedCourses,
- public: isPublic,
- org_id: org.id,
+
+ if (!name.trim()) {
+ toast.error('Please enter a collection name')
+ return
}
- 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
- setTimeout(() => {
+ if (!description.trim()) {
+ 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'))
- }, 1000)
+ } catch (error) {
+ toast.error('Failed to create collection. Please try again.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ if (error) {
+ return (
+
+
Failed to load courses. Please try again later.
+
+ )
}
return (
- <>
-
-
Add new
+
+
+
+
Create New Collection
+
+ Group your courses together in a collection to make them easier to find and manage.
+
+
-
+
)
}
diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx
index dfa4b147..2b1f199a 100644
--- a/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx
+++ b/apps/web/app/orgs/[orgslug]/(withmenu)/collections/page.tsx
@@ -136,7 +136,7 @@ const CollectionsPage = async (params: any) => {
text="Create a collection to add content"
/>
-
+
+
+
+ )
+}
+
function OrgPage({ params }: { params: OrgParams }) {
const [H1Label, setH1Label] = React.useState('')
const [H2Label, setH2Label] = React.useState('')
@@ -20,6 +55,12 @@ function OrgPage({ params }: { params: OrgParams }) {
if (params.subpage == 'general') {
setH1Label('General')
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 (
-
+
-
+
{H1Label}
@@ -41,25 +82,15 @@ function OrgPage({ params }: { params: OrgParams }) {
-
-
-
-
+
+ {SETTING_TABS.map((tab) => (
+
+ ))}
@@ -70,6 +101,8 @@ function OrgPage({ params }: { params: OrgParams }) {
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
>
{params.subpage == 'general' ?
: ''}
+ {params.subpage == 'previews' ?
: ''}
+ {params.subpage == 'socials' ?
: ''}
)
diff --git a/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx
index 173ececd..7d52fd43 100644
--- a/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx
+++ b/apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx
@@ -1,10 +1,10 @@
'use client'
-import React, { useState, useEffect } from 'react'
+import React from 'react'
import { motion } from 'framer-motion'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import Link from 'next/link'
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 { useOrg } from '@components/Contexts/OrgContext'
import PaymentsConfigurationPage from '@components/Dashboard/Pages/Payments/PaymentsConfigurationPage'
@@ -20,18 +20,37 @@ export type PaymentsParams = {
function PaymentsPage({ params }: { params: PaymentsParams }) {
const session = useLHSession() as any
const org = useOrg() as any
- const [selectedSubPage, setSelectedSubPage] = useState(params.subpage || 'general')
- const [H1Label, setH1Label] = useState('')
- const [H2Label, setH2Label] = useState('')
+ const subpage = params.subpage || 'customers'
const isPaymentsEnabled = useFeatureFlag({
path: ['features', 'payments', 'enabled'],
defaultValue: false
})
- useEffect(() => {
- handleLabels()
- }, [selectedSubPage])
+ const getPageTitle = () => {
+ switch (subpage) {
+ 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) {
return (
@@ -45,66 +64,41 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
)
}
- function handleLabels() {
- 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')
- }
- }
+ const { h1, h2 } = getPageTitle()
return (
-
+
-
+
- {H1Label}
+ {h1}
- {H2Label}{' '}
+ {h2}
-
+
}
label="Customers"
- isActive={selectedSubPage === 'customers'}
- onClick={() => setSelectedSubPage('customers')}
+ isActive={subpage === 'customers'}
/>
}
label="Products & Subscriptions"
- isActive={selectedSubPage === 'paid-products'}
- onClick={() => setSelectedSubPage('paid-products')}
+ isActive={subpage === 'paid-products'}
/>
}
label="Configuration"
- isActive={selectedSubPage === 'configuration'}
- onClick={() => setSelectedSubPage('configuration')}
+ isActive={subpage === 'configuration'}
/>
-
@@ -115,21 +109,18 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="flex-1 overflow-y-auto"
>
- {selectedSubPage === 'general' &&
General
}
- {selectedSubPage === 'configuration' &&
}
- {selectedSubPage === 'paid-products' &&
}
- {selectedSubPage === 'customers' &&
}
+ {subpage === 'configuration' &&
}
+ {subpage === 'paid-products' &&
}
+ {subpage === 'customers' &&
}
)
}
-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 }) => (
{icon}
diff --git a/apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx b/apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx
index 032d4e69..41b09c65 100644
--- a/apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx
+++ b/apps/web/components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral.tsx
@@ -1,106 +1,112 @@
'use client'
-import React, { useEffect, useState } from 'react'
-import { Field, Form, Formik } from 'formik'
+import React, { useState } from 'react'
+import { Form, Formik } from 'formik'
+import * as Yup from 'yup'
import {
updateOrganization,
- uploadOrganizationLogo,
- uploadOrganizationThumbnail,
} from '@services/settings/org'
-import { UploadCloud, Info } from 'lucide-react'
import { revalidateTags } from '@services/utils/ts/requests'
import { useRouter } from 'next/navigation'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
-import { getOrgLogoMediaDirectory, getOrgThumbnailMediaDirectory } from '@services/media/media'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs"
-import { Toaster, toast } from 'react-hot-toast';
-import { constructAcceptValue } from '@/lib/constants';
+import { toast } from 'react-hot-toast'
+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 { 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 {
name: string
description: string
- slug: string
- logo: string
- email: string
- thumbnail: string
+ about: string
+ label: string
+ explore: boolean
}
-function OrgEditGeneral() {
+const OrgEditGeneral: React.FC = () => {
const router = useRouter()
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const org = useOrg() as any
- const [selectedTab, setSelectedTab] = useState<'logo' | 'thumbnail'>('logo');
- const [localLogo, setLocalLogo] = useState
(null);
- const [localThumbnail, setLocalThumbnail] = useState(null);
- const handleFileChange = async (event: React.ChangeEvent) => {
- 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) => {
- 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 = {
+ const initialValues: OrganizationValues = {
name: org?.name,
- description: org?.description,
- slug: org?.slug,
- logo: org?.logo,
- email: org?.email,
- thumbnail: org?.thumbnail,
+ description: org?.description || '',
+ about: org?.about || '',
+ label: org?.label || '',
+ explore: org?.explore ?? true,
}
const updateOrg = async (values: OrganizationValues) => {
- const loadingToast = toast.loading('Updating organization...');
+ const loadingToast = toast.loading('Updating organization...')
try {
await updateOrganization(org.id, values, access_token)
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) {
- toast.error('Failed to update organization', { id: loadingToast });
+ toast.error('Failed to update organization', { id: loadingToast })
}
}
- useEffect(() => {}, [org])
-
return (
-
-
+
{
setTimeout(() => {
setSubmitting(false)
@@ -108,129 +114,145 @@ function OrgEditGeneral() {
}, 400)
}}
>
- {({ isSubmitting }) => (
+ {({ isSubmitting, values, handleChange, errors, touched, setFieldValue }) => (