chore: commit remaining workspace changes

This commit is contained in:
2026-05-08 21:51:29 +02:00
parent 8d108b8a76
commit ff96ef796e
97 changed files with 18020 additions and 2196 deletions

View File

@@ -0,0 +1,339 @@
import React, { useEffect, useMemo, useState } from 'react'
import { Link, usePage } from '@inertiajs/react'
import SeoHead from '../../components/seo/SeoHead'
function CourseBreadcrumbs({ items = [] }) {
if (!items.length) return null
return (
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-2 text-sm text-slate-400">
{items.map((item, index) => {
const isLast = index === items.length - 1
return (
<React.Fragment key={`${item.label}-${index}`}>
{index > 0 ? <span className="text-slate-600">/</span> : null}
{isLast ? (
<span className="font-medium text-slate-200">{item.label}</span>
) : (
<Link href={item.href} className="transition hover:text-white">
{item.label}
</Link>
)}
</React.Fragment>
)
})}
</nav>
)
}
function ProgressMeter({ progress }) {
const percent = Math.max(0, Math.min(100, Number(progress?.percent || 0)))
return (
<div className="rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.72))] p-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Progress</p>
<p className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-white">{percent}%</p>
</div>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-300">
{progress ? 'In progress' : 'Not started'}
</span>
</div>
<div className="mt-4 h-2.5 overflow-hidden rounded-full bg-white/[0.06]">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(125,211,252,0.95),rgba(251,191,36,0.9))] transition-[width] duration-500"
style={{ width: `${percent}%` }}
/>
</div>
<p className="mt-3 text-sm leading-7 text-slate-300">
{progress
? `${progress.completedRequired}/${progress.totalRequired} required lessons completed`
: 'Start the course to begin tracking progress through required lessons.'}
</p>
</div>
)
}
function LessonChip({ lesson }) {
const thumbnail = lesson?.cover_image_url || lesson?.article_cover_image_url || lesson?.cover_image || lesson?.article_cover_image || ''
const stepLabel = lesson?.course_step_label || null
const stepNumber = Number(lesson?.course_step_number || 0)
const isCompleted = Boolean(lesson?.completed)
const readingMinutes = Number(lesson?.reading_minutes || 0)
const ctaLabel = isCompleted ? 'Review lesson' : 'Open lesson'
return (
<Link
href={lesson.course_url || `/academy/lessons/${lesson.slug}`}
className={[
'group relative overflow-hidden rounded-[32px] border bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(15,23,42,0.64))] shadow-[0_24px_50px_rgba(2,6,23,0.2)] transition duration-200 hover:-translate-y-1 hover:border-sky-300/30 hover:shadow-[0_28px_70px_rgba(14,165,233,0.12)]',
isCompleted ? 'border-emerald-300/25' : 'border-white/10',
].join(' ')}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(125,211,252,0.09),transparent_24%),linear-gradient(135deg,transparent,rgba(255,255,255,0.02))] opacity-70 transition duration-200 group-hover:opacity-100" />
<div className="relative grid gap-0 lg:grid-cols-[172px_minmax(0,1fr)]">
<div className="relative border-b border-white/10 bg-slate-950 lg:border-b-0 lg:border-r">
{thumbnail ? (
<img src={thumbnail} alt="" aria-hidden="true" className="h-40 w-full object-cover lg:h-full" />
) : (
<div className="h-40 w-full bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] lg:h-full" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.04),rgba(2,6,23,0.84))]" />
<div className="absolute inset-x-3 top-3 flex items-start justify-between gap-3">
{lesson.is_required ? (
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/80 backdrop-blur">
Required
</span>
) : (
<span className="rounded-full border border-white/10 bg-black/35 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-white/65 backdrop-blur">
Optional
</span>
)}
{isCompleted ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/25 bg-emerald-300/12 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100 backdrop-blur">
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
<circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.2" />
<path d="M4.75 8.2 7 10.4l4.25-4.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Done
</span>
) : null}
</div>
<div className="absolute inset-x-3 bottom-3 flex items-end justify-between gap-3">
<div>
{stepLabel ? <p className="text-[10px] font-semibold uppercase tracking-[0.24em] text-sky-100/80">{stepLabel}</p> : null}
{stepNumber > 0 ? <p className="mt-1 text-5xl font-semibold tracking-[-0.1em] text-white">{String(stepNumber).padStart(2, '0')}</p> : null}
</div>
</div>
</div>
<div className="p-5 md:p-6">
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_200px] xl:items-start">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
{stepLabel ? <p className="text-[10px] font-semibold uppercase tracking-[0.22em] text-sky-100">{stepLabel}</p> : null}
{lesson.formatted_lesson_number ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.formatted_lesson_number}</span> : null}
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.difficulty || 'lesson'}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{lesson.access_level || 'free'}</span>
{readingMinutes > 0 ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400">{readingMinutes} min</span> : null}
</div>
<h3 className="mt-3 max-w-3xl text-[1.65rem] font-semibold tracking-[-0.05em] text-white transition group-hover:text-sky-100">{lesson.title}</h3>
<p className="mt-2 text-sm text-slate-400">{isCompleted ? 'You already finished this lesson.' : 'Follow this step next in the course path.'}</p>
<div className="mt-4 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
<p className="text-sm leading-7 text-slate-300">{lesson.excerpt || lesson.content_preview || 'Open this lesson inside the course.'}</p>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lesson.lesson_type || 'article'}</span>
{lesson.category_name ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5">{lesson.category_name}</span> : null}
</div>
</div>
<div className="flex h-full flex-col justify-between gap-4 xl:border-l xl:border-white/10 xl:pl-5">
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
<p className={`mt-2 text-sm font-semibold ${isCompleted ? 'text-emerald-100' : 'text-white'}`}>{isCompleted ? 'Completed' : 'Up next'}</p>
</div>
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Access</p>
<p className="mt-2 text-sm font-semibold text-white">{lesson.access_level || 'Free'}</p>
</div>
<div className="rounded-[22px] border border-white/10 bg-black/20 px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Read time</p>
<p className="mt-2 text-sm font-semibold text-white">{readingMinutes > 0 ? `${readingMinutes} min` : 'Quick read'}</p>
</div>
</div>
<div className="flex items-center justify-between gap-3 xl:justify-end">
<span className="text-xs uppercase tracking-[0.16em] text-slate-500">Continue path</span>
<span className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition group-hover:border-sky-300/35 group-hover:bg-sky-300/14 group-hover:text-white">
{ctaLabel}
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="h-4 w-4">
<path d="M3.5 8h9m0 0-3.5-3.5M12.5 8l-3.5 3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</div>
</div>
</div>
</div>
</div>
</Link>
)
}
function SectionBlock({ section, isActive = false }) {
if (!section?.is_visible) return null
return (
<section className={`rounded-[32px] border p-6 transition md:p-7 ${isActive ? 'border-sky-300/25 bg-[linear-gradient(180deg,rgba(14,165,233,0.08),rgba(255,255,255,0.04))] shadow-[0_22px_50px_rgba(14,165,233,0.08)]' : 'border-white/10 bg-white/[0.04]'}`}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Course section</p>
<span className={`rounded-full border px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${isActive ? 'border-sky-300/20 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-black/20 text-slate-300'}`}>
{section.order_num + 1}
</span>
</div>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{section.title}</h2>
{section.description ? <p className="mt-3 max-w-3xl text-sm leading-7 text-slate-300">{section.description}</p> : null}
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-300">{section.lessons?.length || 0} lessons</span>
{isActive ? <span className="rounded-full border border-sky-300/20 bg-sky-300/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-sky-100">Reading now</span> : null}
</div>
</div>
<div className="mt-5 space-y-6">
{(section.lessons || []).map((lesson) => (
<LessonChip key={lesson.course_lesson_id || lesson.id} lesson={lesson} />
))}
</div>
</section>
)
}
export default function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [], pricingUrl }) {
const flash = usePage().props.flash || {}
const cover = course?.cover_image_url || course?.cover_image || course?.teaser_image_url || course?.teaser_image || ''
const progress = course?.progress || null
const sectionJumpItems = useMemo(
() => [
...(unsectionedLessons.length ? [{ id: 'course-outline-core', label: 'Core lessons', count: unsectionedLessons.length }] : []),
...sections
.filter((section) => section?.is_visible)
.map((section) => ({ id: `section-${section.id}`, label: section.title, count: (section.lessons || []).length })),
],
[sections, unsectionedLessons],
)
const [activeJumpId, setActiveJumpId] = useState(sectionJumpItems[0]?.id || null)
const breadcrumbs = [
{ label: 'Academy', href: '/academy' },
{ label: 'Courses', href: '/academy/courses' },
{ label: course?.title || 'Course', href: course?.public_url || '#' },
]
useEffect(() => {
if (!sectionJumpItems.length || typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') {
return undefined
}
const observer = new IntersectionObserver(
(entries) => {
const visibleEntries = entries.filter((entry) => entry.isIntersecting).sort((left, right) => right.intersectionRatio - left.intersectionRatio)
if (!visibleEntries.length) return
setActiveJumpId(visibleEntries[0].target.id)
},
{
rootMargin: '-20% 0px -55% 0px',
threshold: [0.2, 0.45, 0.7],
},
)
const elements = sectionJumpItems.map((item) => document.getElementById(item.id)).filter(Boolean)
elements.forEach((element) => observer.observe(element))
return () => observer.disconnect()
}, [sectionJumpItems])
return (
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
<SeoHead seo={seo || {}} title={course?.title} description={course?.excerpt || course?.description} />
<div className="mx-auto max-w-[1400px] space-y-6">
{flash.success ? <div className="rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
{flash.error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-black/20 shadow-[0_24px_90px_rgba(2,6,23,0.34)]">
<div className="grid gap-0 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div className="relative overflow-hidden p-6 md:p-8 lg:p-10 xl:p-12">
{cover ? <img src={cover} alt="" aria-hidden="true" className="absolute inset-0 h-full w-full object-cover opacity-[0.18]" /> : null}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(125,211,252,0.18),_transparent_28%),radial-gradient(circle_at_78%_26%,_rgba(251,191,36,0.12),_transparent_20%),linear-gradient(135deg,_rgba(2,6,23,0.98),_rgba(15,23,42,0.85))]" />
<div className="relative z-10 max-w-5xl">
<CourseBreadcrumbs items={breadcrumbs} />
<div className="mt-5 flex flex-wrap items-center gap-2.5">
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100">Academy course</span>
<span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{course?.difficulty}</span>
<span className="rounded-full border border-white/10 bg-white/[0.06] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{course?.access_level}</span>
{progress?.percent ? <span className="rounded-full border border-emerald-300/20 bg-emerald-300/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-100">{progress.percent}% complete</span> : null}
</div>
<div className="mt-6">
<h1 className="text-4xl font-semibold tracking-[-0.06em] text-white md:text-5xl lg:text-[3.75rem]">{course?.title}</h1>
{course?.subtitle ? <p className="mt-4 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100/90">{course.subtitle}</p> : null}
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{course?.excerpt || course?.description}</p>
<div className="mt-7 overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/80 shadow-[0_24px_60px_rgba(2,6,23,0.32)]">
{cover ? (
<img src={cover} alt="" aria-hidden="true" className="w-full object-contain" />
) : (
<div className="flex h-[360px] items-center justify-center bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(15,23,42,0.92))] px-6 text-center text-sm text-slate-400">
No course cover image yet
</div>
)}
</div>
</div>
</div>
</div>
<aside className="border-t border-white/10 bg-white/[0.03] p-6 xl:border-l xl:border-t-0 xl:p-8">
<div className="space-y-4 xl:sticky xl:top-6">
<ProgressMeter progress={progress} />
<div className="rounded-[28px] border border-white/10 bg-black/20 p-5">
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Jump through the course</p>
<div className="mt-4 space-y-2">
{sectionJumpItems.length ? (
sectionJumpItems.map((item) => (
<a
key={item.id}
href={`#${item.id}`}
onClick={() => setActiveJumpId(item.id)}
className={`flex items-center justify-between rounded-2xl border px-4 py-3 text-sm transition ${activeJumpId === item.id ? 'border-sky-300/25 bg-sky-300/12 text-white' : 'border-white/10 bg-white/[0.03] text-slate-300 hover:border-white/20 hover:bg-white/[0.06]'}`}
>
<span className="font-medium">{item.label}</span>
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-400">{item.count}</span>
</a>
))
) : (
<p className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">No course outline items are available yet.</p>
)}
</div>
</div>
</div>
</aside>
</div>
</section>
<div className="space-y-6">
{unsectionedLessons.length ? (
<SectionBlock
section={{
order_num: -1,
title: 'Core lessons',
description: 'Lessons shown before the course branches into sections.',
is_visible: true,
lessons: unsectionedLessons,
}}
isActive={activeJumpId === 'course-outline-core'}
/>
) : null}
{sections.filter((section) => section?.is_visible).map((section) => (
<SectionBlock key={section.id} section={section} isActive={activeJumpId === `section-${section.id}`} />
))}
</div>
</div>
</main>
)
}