diff --git a/app/Http/Controllers/Settings/AcademyAdminController.php b/app/Http/Controllers/Settings/AcademyAdminController.php index c96cfff0..2e5e1de5 100644 --- a/app/Http/Controllers/Settings/AcademyAdminController.php +++ b/app/Http/Controllers/Settings/AcademyAdminController.php @@ -78,6 +78,7 @@ final class AcademyAdminController extends Controller 'challenges' => route('admin.academy.challenges.index'), 'submissions' => route('admin.academy.submissions.index'), 'badges' => route('admin.academy.badges.index'), + 'analytics' => route('admin.academy.analytics.overview'), ], ]); } @@ -1812,6 +1813,7 @@ final class AcademyAdminController extends Controller ->map(function ($note): ?array { if (is_string($note)) { $normalized = [ + 'display_type' => '', 'provider' => '', 'model_name' => '', 'notes' => trim($note), @@ -1833,6 +1835,7 @@ final class AcademyAdminController extends Controller } $normalized = [ + 'display_type' => trim((string) ($note['display_type'] ?? '')), 'provider' => trim((string) ($note['provider'] ?? '')), 'model_name' => trim((string) ($note['model_name'] ?? '')), 'notes' => trim((string) ($note['notes'] ?? '')), @@ -1847,6 +1850,7 @@ final class AcademyAdminController extends Controller ]; $hasContent = collect([ + $normalized['display_type'], $normalized['provider'], $normalized['model_name'], $normalized['notes'], @@ -1890,7 +1894,9 @@ final class AcademyAdminController extends Controller $previousPaths ->reject(fn (string $path): bool => in_array($path, $nextPaths, true)) - ->each(fn (string $path): bool => $this->deleteStoredLessonMediaIfLocal($path)); + ->each(function (string $path): void { + $this->deleteStoredLessonMediaIfLocal($path); + }); } private function promptPreviewImageUpload(UpsertAcademyPromptTemplateRequest $request): ?UploadedFile diff --git a/config/sitemaps.php b/config/sitemaps.php index c20c687c..a386bd3d 100644 --- a/config/sitemaps.php +++ b/config/sitemaps.php @@ -83,16 +83,13 @@ return [ 'enabled' => [ 'artworks', - 'academy-lessons', - 'academy-prompts', - 'academy-packs', - 'academy-challenges', 'users', 'tags', 'categories', 'collections', 'cards', 'stories', + 'web-stories', 'news', 'news-google', 'forum-index', diff --git a/resources/js/Pages/Academy/List.jsx b/resources/js/Pages/Academy/List.jsx index d3278279..4a723eb8 100644 --- a/resources/js/Pages/Academy/List.jsx +++ b/resources/js/Pages/Academy/List.jsx @@ -2,6 +2,7 @@ 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 academyHref(section, slug) { return `/academy/${section}/${encodeURIComponent(slug)}` @@ -65,7 +66,15 @@ function itemHref(pageType, item) { return academyHref('challenges', item.slug) } -function PromptLibraryHero({ title, description, items, pricingUrl }) { +function searchResultContentType(pageType) { + if (pageType === 'prompts') return 'academy_prompt' + if (pageType === 'lessons') return 'academy_lesson' + if (pageType === 'packs') return 'academy_prompt_pack' + if (pageType === 'challenges') return 'academy_challenge' + return null +} + +function PromptLibraryHero({ title, description, items, pricingUrl, totalCount }) { const featuredImages = (items || []) .map((item) => item?.preview_image) .filter(Boolean) @@ -102,7 +111,7 @@ function PromptLibraryHero({ title, description, items, pricingUrl }) {
Upgrade preview - {items?.length || 0} prompts in view + {totalCount || 0} prompts available
@@ -134,13 +143,35 @@ function PromptLibraryHero({ title, description, items, pricingUrl }) { ) } -function AcademyCard({ pageType, item }) { +function AcademyCard({ pageType, item, analytics, searchContext, position }) { const lessonSeries = String(item?.series_name || '').trim() const promptPreviewImage = item?.preview_image || '' + const contentType = searchResultContentType(pageType) + const href = itemHref(pageType, item) + const trackSearchClick = () => { + if (!searchContext?.query || !contentType) { + return + } + + trackAcademySearchResultClick(analytics, searchContext, { + contentType, + contentId: item?.id, + position, + }) + } if (pageType === 'prompts') { return ( - +
{promptPreviewImage ? : null}
@@ -170,7 +201,16 @@ function AcademyCard({ pageType, item }) { } return ( - +

{pageType.slice(0, -1)}

@@ -190,16 +230,101 @@ function AcademyCard({ pageType, item }) { ) } -export default function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl }) { +async function fetchAcademyPage(url) { + const response = await fetch(url, { + headers: { + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + credentials: 'same-origin', + }) + + if (!response.ok) { + throw new Error('Failed to load the next page.') + } + + return response.json() +} + +export default function AcademyList({ pageType, title, description, seo, items, filters, categories, pricingUrl, analytics }) { const flash = usePage().props.flash || {} - const visibleItems = Array.isArray(items?.data) ? items.data : [] + useAcademyPageAnalytics(analytics) + const searchContext = analytics?.search ? { + query: analytics.search.query, + normalizedQuery: analytics.search.normalizedQuery, + resultsCount: analytics.search.resultsCount, + filters, + } : null + const initialItems = React.useMemo(() => (Array.isArray(items?.data) ? items.data : []), [items]) + const [visibleItems, setVisibleItems] = React.useState(initialItems) + const [pagination, setPagination] = React.useState({ + currentPage: Number(items?.current_page || 1), + lastPage: Number(items?.last_page || 1), + nextPageUrl: items?.next_page_url || null, + }) + const [loadingMore, setLoadingMore] = React.useState(false) + const sentinelRef = React.useRef(null) + + React.useEffect(() => { + setVisibleItems(initialItems) + setPagination({ + currentPage: Number(items?.current_page || 1), + lastPage: Number(items?.last_page || 1), + nextPageUrl: items?.next_page_url || null, + }) + setLoadingMore(false) + }, [initialItems, items?.current_page, items?.last_page, items?.next_page_url, pageType]) + + const hasMorePages = pageType === 'prompts' && pagination.currentPage < pagination.lastPage && Boolean(pagination.nextPageUrl) + + const loadMore = React.useCallback(async () => { + if (pageType !== 'prompts' || loadingMore || !pagination.nextPageUrl) { + return + } + + setLoadingMore(true) + + try { + const payload = await fetchAcademyPage(pagination.nextPageUrl) + const nextItems = Array.isArray(payload?.data) ? payload.data : [] + + setVisibleItems((current) => [...current, ...nextItems.filter((item) => !current.some((existing) => String(existing.id) === String(item.id)))]) + setPagination({ + currentPage: Number(payload?.current_page || pagination.currentPage), + lastPage: Number(payload?.last_page || pagination.lastPage), + nextPageUrl: payload?.next_page_url || null, + }) + } catch { + setPagination((current) => ({ ...current, nextPageUrl: null })) + } finally { + setLoadingMore(false) + } + }, [loadingMore, pageType, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl]) + + React.useEffect(() => { + const sentinel = sentinelRef.current + + if (!sentinel || !hasMorePages || loadingMore || typeof window === 'undefined' || typeof window.IntersectionObserver !== 'function') { + return undefined + } + + const observer = new window.IntersectionObserver((entries) => { + if (entries[0]?.isIntersecting) { + void loadMore() + } + }, { rootMargin: '360px 0px' }) + + observer.observe(sentinel) + + return () => observer.disconnect() + }, [hasMorePages, loadMore, loadingMore]) return (
- {pageType === 'prompts' ? : ( + {pageType === 'prompts' ? : (
@@ -207,7 +332,7 @@ export default function AcademyList({ pageType, title, description, seo, items,

{title}

{description}

- Upgrade preview + trackUpgradeClick(analytics, { source: `${pageType}_list_hero` })} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Upgrade preview
)} @@ -220,9 +345,19 @@ export default function AcademyList({ pageType, title, description, seo, items, {visibleItems.length === 0 ? (
Nothing matched this Academy view yet.
) : ( -
- {visibleItems.map((item) => )} -
+ <> +
+ {visibleItems.map((item, index) => )} +
+ + {pageType === 'prompts' ? ( +
+ + ) : null} + )}