343 lines
21 KiB
JavaScript
343 lines
21 KiB
JavaScript
import React from 'react'
|
|
import { Link, router, usePage } from '@inertiajs/react'
|
|
import SeoHead from '../../components/seo/SeoHead'
|
|
import NovaSelect from '../../components/ui/NovaSelect'
|
|
import { trackAcademySearchResultClick, trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics'
|
|
|
|
function Breadcrumbs({ items = [] }) {
|
|
if (!items.length) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<nav aria-label="Breadcrumb" className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
|
{items.map((item, index) => {
|
|
const isLast = index === items.length - 1
|
|
|
|
return (
|
|
<React.Fragment key={`${item.label}-${index}`}>
|
|
{isLast ? (
|
|
<span className="text-white/80">{item.label}</span>
|
|
) : (
|
|
<Link href={item.href} className="transition hover:text-white">{item.label}</Link>
|
|
)}
|
|
{!isLast ? <span className="text-slate-600">/</span> : null}
|
|
</React.Fragment>
|
|
)
|
|
})}
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
function formatAccessDate(value) {
|
|
if (!value) {
|
|
return null
|
|
}
|
|
|
|
const parsed = new Date(value)
|
|
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
return null
|
|
}
|
|
|
|
return new Intl.DateTimeFormat(undefined, {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
}).format(parsed)
|
|
}
|
|
|
|
function academyAccessHeading(access) {
|
|
switch (access?.status) {
|
|
case 'staff_access':
|
|
return 'You currently have full staff access to the Academy.'
|
|
case 'grace_period':
|
|
return `${access.tierLabel} access is still active.`
|
|
case 'trialing':
|
|
return `${access.tierLabel} trial is active right now.`
|
|
case 'active':
|
|
return access?.hasPaidAccess ? `${access.tierLabel} access is active.` : 'Your Academy access is active.'
|
|
case 'free':
|
|
return 'You currently have Free access to the Academy.'
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
function academyAccessMeta(access) {
|
|
if (!access?.signedIn) {
|
|
return []
|
|
}
|
|
|
|
const items = [
|
|
{ label: 'Current tier', value: access?.tierLabel || 'Free' },
|
|
{ label: 'Status', value: access?.statusLabel || 'Free access' },
|
|
]
|
|
|
|
const formattedDate = formatAccessDate(access?.expiresAt)
|
|
|
|
if (formattedDate && access?.dateLabel) {
|
|
items.push({ label: access.dateLabel, value: formattedDate })
|
|
} else if (access?.renewsAutomatically) {
|
|
items.push({ label: 'Billing', value: 'Renews automatically' })
|
|
} else if (!access?.hasPaidAccess) {
|
|
items.push({ label: 'Upgrade', value: 'Creator and Pro unlock premium lessons and courses' })
|
|
}
|
|
|
|
return items
|
|
}
|
|
|
|
function CourseCard({ course, analytics = null, searchContext = null, position = null }) {
|
|
const progress = course?.progress || null
|
|
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
|
const trackSearchClick = () => {
|
|
if (!searchContext?.query) {
|
|
return
|
|
}
|
|
|
|
trackAcademySearchResultClick(analytics, searchContext, {
|
|
contentType: 'academy_course',
|
|
contentId: course?.id,
|
|
position,
|
|
})
|
|
}
|
|
|
|
return (
|
|
<Link
|
|
href={course.public_url}
|
|
onClick={trackSearchClick}
|
|
data-academy-content-type={searchContext?.query ? 'academy_course' : undefined}
|
|
data-academy-content-id={searchContext?.query ? course?.id : undefined}
|
|
data-academy-search-query={searchContext?.query || undefined}
|
|
data-academy-search-results-count={searchContext?.resultsCount || undefined}
|
|
data-academy-search-position={position || undefined}
|
|
className="group overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(7,11,18,0.96))] shadow-[0_20px_50px_rgba(2,6,23,0.18)] transition hover:border-amber-200/24 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]"
|
|
>
|
|
<div className="relative aspect-[16/10] overflow-hidden bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(17,24,39,0.94))]">
|
|
{cover ? <img src={cover} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
|
|
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
|
{course?.difficulty ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{course.difficulty}</span> : null}
|
|
{course?.access_level ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{course.access_level}</span> : null}
|
|
{course.is_featured ? <span className="rounded-full border border-amber-300/20 bg-amber-300/15 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100">Featured</span> : null}
|
|
</div>
|
|
<div className="absolute bottom-4 left-4 right-4 flex flex-wrap items-end justify-between gap-3">
|
|
<div className="flex flex-wrap gap-2">
|
|
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{course?.lessons_count || 0} lessons</span>
|
|
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">{course?.estimated_minutes ? `${course.estimated_minutes} min` : 'Flexible pace'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-100/80">Academy course</p>
|
|
{course?.subtitle ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-300">{course.subtitle}</span> : null}
|
|
</div>
|
|
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white transition group-hover:text-amber-50">{course.title}</h2>
|
|
<p className="mt-3 text-sm leading-7 text-slate-300">{course.excerpt || course.description || 'Structured Academy course.'}</p>
|
|
<p className="mt-4 text-xs uppercase tracking-[0.18em] text-slate-500">{progress ? `${progress.percent}% complete` : 'Start fresh'}{course?.access_level ? ` · ${course.access_level}` : ''}</p>
|
|
</div>
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
export default function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl, lessonsUrl, promptLibraryUrl, academyAccess = null, analytics }) {
|
|
const flash = usePage().props.flash || {}
|
|
useAcademyPageAnalytics(analytics)
|
|
const breadcrumbs = [
|
|
{ label: 'Academy', href: '/academy' },
|
|
{ label: 'Courses', href: '/academy/courses' },
|
|
]
|
|
const visibleItems = Array.isArray(items?.data) ? items.data : []
|
|
const totalCourses = Number(items?.total || items?.data?.length || 0)
|
|
const featuredCount = featuredCourses.length
|
|
const featuredCourse = featuredCourses.find((course) => course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image) || featuredCourses[0] || visibleItems[0] || null
|
|
const featuredCover = featuredCourse?.cover_image_url || featuredCourse?.teaser_image_url || featuredCourse?.cover_image || featuredCourse?.teaser_image || ''
|
|
const showSignedInAccess = Boolean(academyAccess?.signedIn)
|
|
const accessHeading = academyAccessHeading(academyAccess)
|
|
const accessMeta = academyAccessMeta(academyAccess)
|
|
const useBillingAction = showSignedInAccess && academyAccess?.hasPaidAccess && academyAccess?.billingUrl
|
|
const primaryActionLabel = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'Renew now' : 'Manage billing') : 'See plans'
|
|
const primaryActionIcon = useBillingAction ? (academyAccess?.status === 'grace_period' ? 'fa-solid fa-rotate-right' : 'fa-solid fa-sliders') : 'fa-solid fa-arrow-up-right-from-square'
|
|
const primaryActionHref = useBillingAction ? academyAccess.billingUrl : pricingUrl
|
|
const searchContext = analytics?.search ? {
|
|
query: analytics.search.query,
|
|
normalizedQuery: analytics.search.normalizedQuery,
|
|
resultsCount: analytics.search.resultsCount,
|
|
filters,
|
|
} : null
|
|
const difficultyOptions = [
|
|
{ value: '', label: 'All levels' },
|
|
{ value: 'beginner', label: 'Beginner' },
|
|
{ value: 'intermediate', label: 'Intermediate' },
|
|
{ value: 'advanced', label: 'Advanced' },
|
|
]
|
|
const accessOptions = [
|
|
{ value: '', label: 'All access' },
|
|
{ value: 'free', label: 'Free' },
|
|
{ value: 'premium', label: 'Premium' },
|
|
{ value: 'mixed', label: 'Mixed' },
|
|
]
|
|
|
|
const handlePrimaryAction = () => {
|
|
if (!useBillingAction) {
|
|
trackUpgradeClick(analytics, { source: 'academy_courses_index_hero_primary' })
|
|
}
|
|
}
|
|
|
|
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={title} description={description} />
|
|
|
|
<div className="mx-auto max-w-[1400px] space-y-6">
|
|
<section className="relative overflow-hidden rounded-[40px] border border-amber-200/12 bg-[linear-gradient(155deg,rgba(251,191,36,0.14),rgba(15,23,42,0.96)_36%,rgba(14,165,233,0.14))] p-5 shadow-[0_32px_96px_rgba(2,6,23,0.32)] md:p-6 xl:p-7">
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_14%,rgba(253,230,138,0.14),transparent_24%),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(180deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:auto,24px_24px,24px_24px] opacity-75" />
|
|
<div className="absolute -left-8 top-12 h-36 w-36 rounded-full bg-amber-300/18 blur-3xl" />
|
|
<div className="absolute -right-10 bottom-0 h-40 w-40 rounded-full bg-sky-300/14 blur-3xl" />
|
|
|
|
<div className="relative grid gap-5 xl:grid-cols-[minmax(0,1.1fr)_360px] xl:items-start">
|
|
<div className="min-w-0">
|
|
<Breadcrumbs items={breadcrumbs} />
|
|
|
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full border border-amber-200/18 bg-amber-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-50/90">Skinbase AI Academy</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-200">Courses</span>
|
|
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">{totalCourses} guided paths</span>
|
|
</div>
|
|
|
|
<div className="mt-4 flex items-start justify-between gap-4">
|
|
<div className="max-w-4xl">
|
|
<h1 className="max-w-[13ch] text-4xl font-semibold tracking-[-0.05em] text-white md:max-w-[14ch] md:text-[4.2rem] xl:max-w-[15ch] xl:text-[4.5rem]">{title}</h1>
|
|
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-200/95">{description}</p>
|
|
</div>
|
|
<span className="hidden h-20 w-20 shrink-0 items-center justify-center rounded-[26px] border border-white/10 bg-white/[0.04] text-2xl text-amber-50 shadow-[0_18px_40px_rgba(2,6,23,0.22)] md:flex">
|
|
<i className="fa-solid fa-route" />
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-6 grid gap-3 sm:grid-cols-2">
|
|
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Library</p>
|
|
<p className="mt-2 text-lg font-semibold text-white">{totalCourses} guided courses</p>
|
|
</div>
|
|
<div className="rounded-[24px] border border-white/10 bg-slate-950/35 px-5 py-4 backdrop-blur-sm">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100/75">Focus</p>
|
|
<p className="mt-2 text-lg font-semibold text-white">Sequenced learning + tracked completion</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-wrap gap-2.5">
|
|
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Structured progression</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Tracked completion</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-[11px] font-semibold tracking-[0.16em] text-amber-50/90">Reusable lesson paths</span>
|
|
</div>
|
|
|
|
<div className="mt-6 flex flex-wrap gap-3">
|
|
<Link href={lessonsUrl || '/academy/lessons'} className="inline-flex items-center gap-2 rounded-full border border-amber-200/26 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-50 transition hover:border-amber-200/36 hover:bg-amber-300/18">
|
|
<i className="fa-solid fa-book-open-reader text-xs" />
|
|
Browse lessons
|
|
</Link>
|
|
<Link href={promptLibraryUrl || '/academy/prompts'} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
|
<i className="fa-solid fa-wand-magic-sparkles text-xs" />
|
|
Prompt library
|
|
</Link>
|
|
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/20 bg-sky-300/10 px-5 py-3 text-sm font-semibold text-sky-50 transition hover:border-sky-200/30 hover:bg-sky-300/16">
|
|
<i className={`${primaryActionIcon} text-xs`} />
|
|
{primaryActionLabel}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="rounded-[28px] border border-white/10 bg-black/20 p-4 shadow-[0_18px_42px_rgba(2,6,23,0.18)] backdrop-blur-sm md:p-5">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Featured course</p>
|
|
{featuredCourse?.difficulty ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/80">{featuredCourse.difficulty}</span> : null}
|
|
</div>
|
|
<Link href={featuredCourse?.public_url || '#academy-courses-grid'} className="group mt-4 block overflow-hidden rounded-[24px] border border-white/10 bg-white/[0.04] transition hover:border-amber-200/24 hover:bg-white/[0.06]">
|
|
<div className="relative aspect-[16/10] overflow-hidden bg-[linear-gradient(135deg,rgba(251,191,36,0.18),rgba(17,24,39,0.94))]">
|
|
{featuredCover ? <img src={featuredCover} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
|
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.78))]" />
|
|
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
|
{featuredCourse?.access_level ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-amber-100">{featuredCourse.access_level}</span> : null}
|
|
{featuredCourse?.is_featured ? <span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white">Spotlight</span> : null}
|
|
</div>
|
|
</div>
|
|
<div className="p-4">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{featuredCourse?.subtitle || 'Guided learning path'}</p>
|
|
<h3 className="mt-2 text-lg font-semibold tracking-[-0.03em] text-white transition group-hover:text-amber-50">{featuredCourse?.title || 'Explore courses'}</h3>
|
|
<p className="mt-2 text-sm leading-7 text-slate-300">{featuredCourse?.excerpt || featuredCourse?.description || 'Open a structured Academy course built from reusable lessons.'}</p>
|
|
</div>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="xl:col-span-2">
|
|
<div className="rounded-[28px] border border-sky-300/20 bg-[linear-gradient(135deg,rgba(56,189,248,0.16),rgba(15,23,42,0.82))] p-4 shadow-[0_18px_45px_rgba(2,6,23,0.2)] md:p-5 lg:p-6">
|
|
<div className="flex items-start gap-3">
|
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl border border-sky-300/25 bg-sky-300/12 text-sky-100"><i className="fa-solid fa-crown text-sm" /></span>
|
|
<div className="min-w-0">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-sky-100/80">{showSignedInAccess ? 'Your Academy access' : 'Upgrade for full access'}</p>
|
|
<p className="mt-1 max-w-4xl text-lg font-semibold text-white md:text-[1.85rem] md:leading-[1.45]">{showSignedInAccess ? accessHeading : 'Unlock the full course library, premium lesson paths, and the broader Academy learning track.'}</p>
|
|
</div>
|
|
</div>
|
|
{showSignedInAccess ? (
|
|
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{accessMeta.map((item) => (
|
|
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 md:px-5 md:py-4">
|
|
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-sky-100/70">{item.label}</p>
|
|
<p className="mt-2 text-sm font-semibold text-white md:text-base">{item.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<div className="mt-4 flex flex-wrap gap-3">
|
|
<Link href={primaryActionHref} onClick={handlePrimaryAction} className="inline-flex items-center gap-2 rounded-full border border-sky-200/30 bg-sky-200/15 px-5 py-2.5 text-sm font-semibold text-sky-50 transition hover:bg-sky-200/22">
|
|
<i className={`${primaryActionIcon} text-xs`} />
|
|
{primaryActionLabel}
|
|
</Link>
|
|
<Link href={lessonsUrl || '/academy/lessons'} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-5 py-2.5 text-sm font-semibold text-white/90 transition hover:bg-white/[0.1]">
|
|
<i className="fa-solid fa-book-open-reader text-xs" />
|
|
Browse lessons
|
|
</Link>
|
|
</div>
|
|
{academyAccess?.status === 'grace_period' ? <p className="mt-2 text-xs text-sky-100/75">Opens billing account to restore renewal before access ends.</p> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{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="grid gap-3 rounded-[30px] border border-white/10 bg-black/20 p-5 md:grid-cols-2">
|
|
<NovaSelect
|
|
label="Difficulty"
|
|
value={filters?.difficulty || ''}
|
|
onChange={(nextValue) => router.get(window.location.pathname, { ...filters, difficulty: nextValue || undefined }, { preserveScroll: true, preserveState: true })}
|
|
options={difficultyOptions}
|
|
searchable={false}
|
|
className="rounded-2xl bg-white/[0.04]"
|
|
/>
|
|
<NovaSelect
|
|
label="Access"
|
|
value={filters?.access || ''}
|
|
onChange={(nextValue) => router.get(window.location.pathname, { ...filters, access: nextValue || undefined }, { preserveScroll: true, preserveState: true })}
|
|
options={accessOptions}
|
|
searchable={false}
|
|
className="rounded-2xl bg-white/[0.04]"
|
|
/>
|
|
</section>
|
|
|
|
{visibleItems.length === 0 ? (
|
|
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400">No published Academy courses matched these filters.</section>
|
|
) : (
|
|
<section id="academy-courses-grid" className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
|
{visibleItems.map((course, index) => <CourseCard key={course.id} course={course} analytics={analytics} searchContext={searchContext} position={index + 1} />)}
|
|
</section>
|
|
)}
|
|
</div>
|
|
</main>
|
|
)
|
|
} |