From 44fb6b26b82b5ccf268cf3fb66c69b018eae7687 Mon Sep 17 00:00:00 2001 From: swve Date: Sat, 1 Mar 2025 16:57:18 +0100 Subject: [PATCH 01/12] feat: Add landing configuration support for organizations --- apps/api/src/db/organization_config.py | 4 +- apps/api/src/routers/orgs.py | 15 +++++++ apps/api/src/services/install/install.py | 5 ++- apps/api/src/services/orgs/orgs.py | 51 ++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/api/src/db/organization_config.py b/apps/api/src/db/organization_config.py index a54e537e..5812179e 100644 --- a/apps/api/src/db/organization_config.py +++ b/apps/api/src/db/organization_config.py @@ -82,6 +82,7 @@ class OrgGeneralConfig(BaseModel): color: str = "normal" watermark: bool = True + # Cloud class OrgCloudConfig(BaseModel): plan: Literal["free", "standard", "pro"] = "free" @@ -90,10 +91,11 @@ class OrgCloudConfig(BaseModel): # Main Config class OrganizationConfigBase(BaseModel): - config_version: str = "1.2" + config_version: str = "1.3" general: OrgGeneralConfig features: OrgFeatureConfig cloud: OrgCloudConfig + landing: dict = {} class OrganizationConfig(SQLModel, table=True): diff --git a/apps/api/src/routers/orgs.py b/apps/api/src/routers/orgs.py index e6025829..e3cdc735 100644 --- a/apps/api/src/routers/orgs.py +++ b/apps/api/src/routers/orgs.py @@ -40,6 +40,7 @@ from src.services.orgs.orgs import ( update_org_preview, update_org_signup_mechanism, update_org_thumbnail, + update_org_landing, ) @@ -413,3 +414,17 @@ async def api_delete_org( """ return await delete_org(request, org_id, current_user, db_session) + + +@router.put("/{org_id}/landing") +async def api_update_org_landing( + request: Request, + org_id: int, + landing_object: dict, + current_user: PublicUser = Depends(get_current_user), + db_session: Session = Depends(get_db_session), +): + """ + Update organization landing object + """ + return await update_org_landing(request, landing_object, org_id, current_user, db_session) diff --git a/apps/api/src/services/install/install.py b/apps/api/src/services/install/install.py index 84841fb4..aea4551e 100644 --- a/apps/api/src/services/install/install.py +++ b/apps/api/src/services/install/install.py @@ -330,7 +330,7 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess # Org Config org_config = OrganizationConfigBase( - config_version="1.2", + config_version="1.3", general=OrgGeneralConfig( enabled=True, color="normal", @@ -354,7 +354,8 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess cloud=OrgCloudConfig( plan='free', custom_domain=False - ) + ), + landing={} ) org_config = json.loads(org_config.json()) diff --git a/apps/api/src/services/orgs/orgs.py b/apps/api/src/services/orgs/orgs.py index 447e26b1..c66e80b3 100644 --- a/apps/api/src/services/orgs/orgs.py +++ b/apps/api/src/services/orgs/orgs.py @@ -714,6 +714,57 @@ async def upload_org_preview_service( "filename": name_in_disk } +async def update_org_landing( + request: Request, + landing_object: dict, + org_id: int, + 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) + + # Get org config + statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org.id) + result = db_session.exec(statement) + + org_config = result.first() + + if org_config is None: + logging.error(f"Organization {org_id} has no config") + raise HTTPException( + status_code=404, + detail="Organization config not found", + ) + + # Convert to OrganizationConfigBase model and back to ensure all fields exist + config_model = OrganizationConfigBase(**org_config.config) + + # Update the landing object + config_model.landing = landing_object + + # Convert back to dict and update + updated_config = json.loads(config_model.json()) + org_config.config = updated_config + org_config.update_date = str(datetime.now()) + + db_session.add(org_config) + db_session.commit() + db_session.refresh(org_config) + + return {"detail": "Landing object updated"} + ## 🔒 RBAC Utils ## From f6f915c956042360f570ad1bbe6c893cfa58f36e Mon Sep 17 00:00:00 2001 From: swve Date: Sun, 2 Mar 2025 10:47:19 +0100 Subject: [PATCH 02/12] feat: init of landing page editor --- .../dash/org/settings/[subpage]/page.tsx | 8 +- .../Org/OrgEditLanding/OrgEditLanding.tsx | 1240 +++++++++++++++++ .../Pages/Org/OrgEditLanding/landing_types.ts | 83 ++ apps/web/services/organizations/orgs.ts | 13 + 4 files changed, 1343 insertions(+), 1 deletion(-) create mode 100644 apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx create mode 100644 apps/web/components/Dashboard/Pages/Org/OrgEditLanding/landing_types.ts diff --git a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx index 44a886d5..481cd66c 100644 --- a/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/dash/org/settings/[subpage]/page.tsx @@ -1,13 +1,14 @@ 'use client' import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs' import { getUriWithOrg } from '@services/config/config' -import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon } from 'lucide-react' +import { ImageIcon, Info, LockIcon, SearchIcon, TextIcon, LucideIcon, Share2Icon, LayoutDashboardIcon } from 'lucide-react' import Link from 'next/link' import React, { useEffect } from 'react' import { motion } from 'framer-motion' 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' +import OrgEditLanding from '@components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding' export type OrgParams = { subpage: string @@ -22,6 +23,7 @@ interface TabItem { const SETTING_TABS: TabItem[] = [ { id: 'general', label: 'General', icon: TextIcon }, + { id: 'landing', label: 'Landing Page', icon: LayoutDashboardIcon }, { id: 'previews', label: 'Images & Previews', icon: ImageIcon }, { id: 'socials', label: 'Socials', icon: Share2Icon }, ] @@ -61,6 +63,9 @@ function OrgPage({ params }: { params: OrgParams }) { } else if (params.subpage == 'socials') { setH1Label('Socials') setH2Label('Manage your organization social media links') + } else if (params.subpage == 'landing') { + setH1Label('Landing Page') + setH2Label('Customize your organization landing page') } } @@ -103,6 +108,7 @@ function OrgPage({ params }: { params: OrgParams }) { {params.subpage == 'general' ? : ''} {params.subpage == 'previews' ? : ''} {params.subpage == 'socials' ? : ''} + {params.subpage == 'landing' ? : ''} ) diff --git a/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx b/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx new file mode 100644 index 00000000..33b27dd6 --- /dev/null +++ b/apps/web/components/Dashboard/Pages/Org/OrgEditLanding/OrgEditLanding.tsx @@ -0,0 +1,1240 @@ +'use client' +import React from 'react' +import { LandingObject, LandingSection, LandingHeroSection, LandingTextAndImageSection, LandingLogos, LandingPeople, LandingBackground, LandingButton, LandingHeading, LandingImage, LandingFeaturedCourses } from './landing_types' +import { Plus, Eye, ArrowUpDown, Trash2, GripVertical, LayoutTemplate, ImageIcon, Users, Award, ArrowRight, Edit, Link, Upload, Save, BookOpen } from 'lucide-react' +import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd' +import { Input } from "@components/ui/input" +import { Textarea } from "@components/ui/textarea" +import { Label } from "@components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@components/ui/select" +import { Button } from "@components/ui/button" +import { useOrg } from '@components/Contexts/OrgContext' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { updateOrgLanding } from '@services/organizations/orgs' +import { getOrgCourses } from '@services/courses/courses' +import toast from 'react-hot-toast' +import useSWR from 'swr' + +const SECTION_TYPES = { + hero: { + icon: LayoutTemplate, + label: 'Hero', + description: 'Add a hero section with heading and call-to-action' + }, + 'text-and-image': { + icon: ImageIcon, + label: 'Text & Image', + description: 'Add a section with text and an image' + }, + logos: { + icon: Award, + label: 'Logos', + description: 'Add a section to showcase logos' + }, + people: { + icon: Users, + label: 'People', + description: 'Add a section to highlight team members' + }, + 'featured-courses': { + icon: BookOpen, + label: 'Courses', + description: 'Add a section to showcase selected courses' + } +} as const + +const PREDEFINED_GRADIENTS = { + 'sunrise': { + colors: ['#fef9f3', '#ffecd2'] as Array, + direction: '45deg' + }, + 'mint-breeze': { + colors: ['#f0fff4', '#dcfce7'] as Array, + direction: '45deg' + }, + 'lavender-mist': { + colors: ['#faf5ff', '#f3e8ff'] as Array, + direction: '45deg' + }, + 'ocean-spray': { + colors: ['#f0f9ff', '#e0f2fe'] as Array, + direction: '45deg' + }, + 'peach-cream': { + colors: ['#fff7ed', '#ffedd5'] as Array, + direction: '45deg' + } +} as const + +const GRADIENT_DIRECTIONS = { + '45deg': '↗️ Top Right', + '90deg': '⬆️ Top', + '135deg': '↖️ Top Left', + '180deg': '⬅️ Left', + '225deg': '↙️ Bottom Left', + '270deg': '⬇️ Bottom', + '315deg': '↘️ Bottom Right', + '0deg': '➡️ Right' +} as const + +const getSectionDisplayName = (section: LandingSection) => { + return SECTION_TYPES[section.type as keyof typeof SECTION_TYPES].label +} + +const OrgEditLanding = () => { + const org = useOrg() as any + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token + const [isLandingEnabled, setIsLandingEnabled] = React.useState(false) + const [landingData, setLandingData] = React.useState({ + sections: [], + enabled: false + }) + const [selectedSection, setSelectedSection] = React.useState(null) + const [isSaving, setIsSaving] = React.useState(false) + + // Initialize landing data from org config + React.useEffect(() => { + if (org?.config?.config?.landing) { + const landingConfig = org.config.config.landing + setLandingData({ + sections: landingConfig.sections || [], + enabled: landingConfig.enabled || false + }) + setIsLandingEnabled(landingConfig.enabled || false) + } + }, [org]) + + const addSection = (type: string) => { + const newSection: LandingSection = createEmptySection(type) + setLandingData(prev => ({ + ...prev, + sections: [...prev.sections, newSection] + })) + } + + const createEmptySection = (type: string): LandingSection => { + switch (type) { + case 'hero': + return { + type: 'hero', + title: 'New Hero Section', + background: { + type: 'solid', + color: '#ffffff' + }, + heading: { + text: 'Welcome', + color: '#000000', + size: 'large' + }, + subheading: { + text: 'Start your learning journey', + color: '#666666', + size: 'medium' + }, + buttons: [] + } + case 'text-and-image': + return { + type: 'text-and-image', + title: 'New Text & Image Section', + text: 'Add your content here', + flow: 'left', + image: { + url: '', + alt: '' + }, + buttons: [] + } + case 'logos': + return { + type: 'logos', + logos: [] + } + case 'people': + return { + type: 'people', + title: 'New People Section', + people: [] + } + case 'featured-courses': + return { + type: 'featured-courses', + title: 'Courses', + courses: [] + } + default: + throw new Error('Invalid section type') + } + } + + const updateSection = (index: number, updatedSection: LandingSection) => { + const newSections = [...landingData.sections] + newSections[index] = updatedSection + setLandingData(prev => ({ + ...prev, + sections: newSections + })) + } + + const deleteSection = (index: number) => { + setLandingData(prev => ({ + ...prev, + sections: prev.sections.filter((_, i) => i !== index) + })) + setSelectedSection(null) + } + + const onDragEnd = (result: any) => { + if (!result.destination) return + + const items = Array.from(landingData.sections) + const [reorderedItem] = items.splice(result.source.index, 1) + items.splice(result.destination.index, 0, reorderedItem) + + setLandingData(prev => ({ + ...prev, + sections: items + })) + setSelectedSection(result.destination.index) + } + + const handleSave = async () => { + if (!org?.id) { + toast.error('Organization ID not found') + return + } + + setIsSaving(true) + try { + const res = await updateOrgLanding(org.id, { + sections: landingData.sections, + enabled: isLandingEnabled + }, access_token) + + if (res.status === 200) { + toast.success('Landing page saved successfully') + } else { + toast.error('Error saving landing page') + } + } catch (error) { + toast.error('Error saving landing page') + console.error('Error saving landing page:', error) + } finally { + setIsSaving(false) + } + } + + return ( +
+
+ {/* Enable/Disable Landing Page */} +
+
+

Landing Page

+

Customize your organization's landing page

+
+
+ + +
+
+ + {isLandingEnabled && ( + <> + {/* Section List */} +
+ {/* Sections Panel */} +
+

Sections

+ + + {(provided) => ( +
+ {landingData.sections.map((section, index) => ( + + {(provided, snapshot) => ( +
+
+
+
+ +
+ {React.createElement(SECTION_TYPES[section.type as keyof typeof SECTION_TYPES].icon, { + size: 16, + className: "text-gray-600" + })} + + {getSectionDisplayName(section)} + +
+
+ + +
+
+
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ +
+ +
+
+ + {/* Editor Panel */} +
+ {selectedSection !== null ? ( + updateSection(selectedSection, updatedSection)} + /> + ) : ( +
+ Select a section to edit or add a new one +
+ )} +
+
+ + )} +
+
+ ) +} + +interface SectionEditorProps { + section: LandingSection + onChange: (section: LandingSection) => void +} + +const SectionEditor: React.FC = ({ section, onChange }) => { + switch (section.type) { + case 'hero': + return + case 'text-and-image': + return + case 'logos': + return + case 'people': + return + case 'featured-courses': + return + default: + return
Unknown section type
+ } +} + +const HeroSectionEditor: React.FC<{ + section: LandingHeroSection + onChange: (section: LandingHeroSection) => void +}> = ({ section, onChange }) => { + const handleImageUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + const reader = new FileReader() + reader.onloadend = () => { + onChange({ + ...section, + background: { + type: 'image', + image: reader.result as string + } + }) + } + reader.readAsDataURL(file) + } + } + + return ( +
+
+ +

Hero Section

+
+ +
+ {/* Title */} +
+ + onChange({ ...section, title: e.target.value })} + placeholder="Enter section title" + /> +
+ + {/* Background */} +
+
+ + +
+ + {section.background.type === 'solid' && ( +
+ +
+ onChange({ + ...section, + background: { ...section.background, color: e.target.value } + })} + className="w-20 h-10 p-1" + /> + onChange({ + ...section, + background: { ...section.background, color: e.target.value } + })} + placeholder="#ffffff" + className="font-mono" + /> +
+
+ )} + + {section.background.type === 'gradient' && ( +
+
+ + +
+ + {!Object.values(PREDEFINED_GRADIENTS).some( + preset => preset.colors[0] === section.background.colors?.[0] && + preset.colors[1] === section.background.colors?.[1] + ) ? ( +
+
+ +
+ onChange({ + ...section, + background: { + ...section.background, + colors: [e.target.value, section.background.colors?.[1] || '#f0f0f0'] + } + })} + className="w-20 h-10 p-1" + /> + onChange({ + ...section, + background: { + ...section.background, + colors: [e.target.value, section.background.colors?.[1] || '#f0f0f0'] + } + })} + placeholder="#ffffff" + className="font-mono" + /> +
+
+ +
+ +
+ onChange({ + ...section, + background: { + ...section.background, + colors: [section.background.colors?.[0] || '#ffffff', e.target.value] + } + })} + className="w-20 h-10 p-1" + /> + onChange({ + ...section, + background: { + ...section.background, + colors: [section.background.colors?.[0] || '#ffffff', e.target.value] + } + })} + placeholder="#f0f0f0" + className="font-mono" + /> +
+
+
+ ) : ( +
+ + +
+ )} + +
+ + +
+ +
+
+
+
+ )} + + {section.background.type === 'image' && ( +
+
+ +
+ + +
+ {section.background.image && ( +
+ Background preview +
+ )} +
+
+ )} +
+ + {/* Heading */} +
+
+ + onChange({ + ...section, + heading: { ...section.heading, text: e.target.value } + })} + placeholder="Enter heading text" + /> +
+
+ +
+ onChange({ + ...section, + heading: { ...section.heading, color: e.target.value } + })} + className="w-20 h-10 p-1" + /> + onChange({ + ...section, + heading: { ...section.heading, color: e.target.value } + })} + placeholder="#000000" + className="font-mono" + /> +
+
+
+ + {/* Subheading */} +
+
+ + onChange({ + ...section, + subheading: { ...section.subheading, text: e.target.value } + })} + placeholder="Enter subheading text" + /> +
+
+ +
+ onChange({ + ...section, + subheading: { ...section.subheading, color: e.target.value } + })} + className="w-20 h-10 p-1" + /> + onChange({ + ...section, + subheading: { ...section.subheading, color: e.target.value } + })} + placeholder="#666666" + className="font-mono" + /> +
+
+
+ + {/* Buttons */} +
+ +
+ {section.buttons.map((button, index) => ( +
+
+ { + const newButtons = [...section.buttons] + newButtons[index] = { ...button, text: e.target.value } + onChange({ ...section, buttons: newButtons }) + }} + placeholder="Button text" + /> +
+ { + const newButtons = [...section.buttons] + newButtons[index] = { ...button, color: e.target.value } + onChange({ ...section, buttons: newButtons }) + }} + className="w-10 h-8 p-1" + /> + { + const newButtons = [...section.buttons] + newButtons[index] = { ...button, background: e.target.value } + onChange({ ...section, buttons: newButtons }) + }} + className="w-10 h-8 p-1" + /> +
+
+
+
+ + { + const newButtons = [...section.buttons] + newButtons[index] = { ...button, link: e.target.value } + onChange({ ...section, buttons: newButtons }) + }} + placeholder="Button link" + /> +
+
+ +
+ ))} + {section.buttons.length < 2 && ( + + )} +
+
+
+
+ ) +} + +const TextAndImageSectionEditor: React.FC<{ + section: LandingTextAndImageSection + onChange: (section: LandingTextAndImageSection) => void +}> = ({ section, onChange }) => { + return ( +
+
+ +

Text & Image Section

+
+ +
+ {/* Title */} +
+ + onChange({ ...section, title: e.target.value })} + placeholder="Enter section title" + /> +
+ + {/* Text */} +
+ +