feat: Init explore metadata features & redesign org settings panel

This commit is contained in:
swve 2024-12-18 00:24:37 +01:00
parent 87787724c4
commit bfd27ef6e3
12 changed files with 1419 additions and 217 deletions

View file

@ -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<string | null>(null);
const [localThumbnail, setLocalThumbnail] = useState<string | null>(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))
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 = {
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 (
<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">
<Toaster />
<div className="sm:mx-10 mx-0 bg-white rounded-xl nice-shadow ">
<Formik
enableReinitialize
initialValues={orgValues}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values, { setSubmitting }) => {
setTimeout(() => {
setSubmitting(false)
@ -108,129 +114,145 @@ function OrgEditGeneral() {
}, 400)
}}
>
{({ isSubmitting }) => (
{({ isSubmitting, values, handleChange, errors, touched, setFieldValue }) => (
<Form>
<div className="flex flex-col lg:flex-row lg:space-x-8">
<div className="w-full lg:w-1/2 mb-8 lg:mb-0">
<label className="block mb-2 font-bold" htmlFor="name">
Name
</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="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 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">
Organization Settings
</h1>
<h2 className="text-gray-500 text-md">
Manage your organization's profile and settings
</h2>
</div>
<div className="w-full lg:w-1/2">
<Tabs defaultValue="logo" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6 sm:mb-10">
<TabsTrigger value="logo">Logo</TabsTrigger>
<TabsTrigger value="thumbnail">Thumbnail</TabsTrigger>
</TabsList>
<TabsContent value="logo">
<div className="flex flex-col space-y-3">
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow mx-4 sm:mx-10">
<div className="flex flex-col justify-center items-center mt-6 sm:mt-10">
<div
className="w-[150px] sm:w-[200px] h-[75px] sm:h-[100px] bg-contain bg-no-repeat bg-center rounded-lg nice-shadow bg-white"
style={{ backgroundImage: `url(${localLogo || getOrgLogoMediaDirectory(org?.org_uuid, org?.logo_image)})` }}
/>
</div>
<div className="flex justify-center items-center">
<input
type="file"
id="fileInput"
accept={SUPPORTED_FILES}
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 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 className="space-y-4">
<div>
<Label htmlFor="name">
Organization Name
<span className="text-gray-500 text-sm ml-2">
({60 - (values.name?.length || 0)} characters left)
</span>
</Label>
<Input
id="name"
name="name"
value={values.name}
onChange={handleChange}
placeholder="Organization Name"
maxLength={60}
/>
{touched.name && errors.name && (
<p className="text-red-500 text-sm mt-1">{errors.name}</p>
)}
</div>
</TabsContent>
<TabsContent value="thumbnail">
<div className="flex flex-col space-y-3">
<div className="w-auto bg-gray-50 rounded-xl outline outline-1 outline-gray-200 h-[200px] shadow mx-4 sm:mx-10">
<div className="flex flex-col justify-center items-center mt-6 sm:mt-10">
<div
className="w-[150px] sm:w-[200px] h-[75px] sm:h-[100px] bg-contain bg-no-repeat bg-center rounded-lg nice-shadow bg-white"
style={{ backgroundImage: `url(${localThumbnail || getOrgThumbnailMediaDirectory(org?.org_uuid, org?.thumbnail_image)})` }}
/>
</div>
<div className="flex justify-center items-center">
<input
type="file"
accept={SUPPORTED_FILES}
id="thumbnailInput"
style={{ display: 'none' }}
onChange={handleThumbnailChange}
/>
<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>
<Label htmlFor="description">
Short Description
<span className="text-gray-500 text-sm ml-2">
({100 - (values.description?.length || 0)} characters left)
</span>
</Label>
<Input
id="description"
name="description"
value={values.description}
onChange={handleChange}
placeholder="Brief description of your organization"
maxLength={100}
/>
{touched.description && errors.description && (
<p className="text-red-500 text-sm mt-1">{errors.description}</p>
)}
</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>
</Form>