Implement academy analytics, billing, and web stories updates
This commit is contained in:
77
resources/js/Pages/Admin/Academy/AnalyticsContent.jsx
Normal file
77
resources/js/Pages/Admin/Academy/AnalyticsContent.jsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react'
|
||||
import { Head } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import AnalyticsNav from './AnalyticsNav'
|
||||
|
||||
function MetricCell({ value, suffix = '' }) {
|
||||
return <span className="font-semibold text-white">{value}{suffix}</span>
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsContent({ nav = [], range, title, subtitle, rows = [] }) {
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
<Head title={`Admin · ${title}`} />
|
||||
|
||||
<div className="space-y-6">
|
||||
<AnalyticsNav items={nav} />
|
||||
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Range</p>
|
||||
<p className="mt-3 text-sm text-slate-300">{range?.from} to {range?.to}</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/[0.08] bg-white/[0.03]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead className="border-b border-white/[0.08] bg-black/20 text-[11px] uppercase tracking-[0.18em] text-slate-500">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Title</th>
|
||||
<th className="px-4 py-3">Type</th>
|
||||
<th className="px-4 py-3">Access</th>
|
||||
<th className="px-4 py-3">Views</th>
|
||||
<th className="px-4 py-3">Unique</th>
|
||||
<th className="px-4 py-3">Engaged</th>
|
||||
<th className="px-4 py-3">Likes</th>
|
||||
<th className="px-4 py-3">Saves</th>
|
||||
<th className="px-4 py-3">Copies</th>
|
||||
<th className="px-4 py-3">Starts</th>
|
||||
<th className="px-4 py-3">Completions</th>
|
||||
<th className="px-4 py-3">Upgrade Clicks</th>
|
||||
<th className="px-4 py-3">Popularity</th>
|
||||
<th className="px-4 py-3">Trend</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length ? rows.map((row) => (
|
||||
<tr key={`${row.content_type}-${row.content_id || 'none'}`} className="border-b border-white/[0.06] align-top text-slate-300">
|
||||
<td className="px-4 py-4">
|
||||
<p className="font-semibold text-white">{row.title}</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">ID {row.content_id || 'n/a'}</p>
|
||||
</td>
|
||||
<td className="px-4 py-4">{row.content_type_label}</td>
|
||||
<td className="px-4 py-4">{row.access_level || 'n/a'}</td>
|
||||
<td className="px-4 py-4"><MetricCell value={row.views} /></td>
|
||||
<td className="px-4 py-4"><MetricCell value={row.unique_visitors} /></td>
|
||||
<td className="px-4 py-4"><MetricCell value={row.engaged_views} /></td>
|
||||
<td className="px-4 py-4"><MetricCell value={row.likes} /></td>
|
||||
<td className="px-4 py-4"><MetricCell value={row.saves} /></td>
|
||||
<td className="px-4 py-4"><MetricCell value={row.prompt_copies} /></td>
|
||||
<td className="px-4 py-4"><MetricCell value={row.starts} /></td>
|
||||
<td className="px-4 py-4"><MetricCell value={row.completions} /></td>
|
||||
<td className="px-4 py-4"><MetricCell value={row.upgrade_clicks} /></td>
|
||||
<td className="px-4 py-4"><MetricCell value={row.popularity_score} /></td>
|
||||
<td className="px-4 py-4">{row.trend}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan={14} className="px-4 py-10 text-center text-slate-400">No rollup data available yet for this view.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
60
resources/js/Pages/Admin/Academy/AnalyticsFunnel.jsx
Normal file
60
resources/js/Pages/Admin/Academy/AnalyticsFunnel.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react'
|
||||
import { Head } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import AnalyticsNav from './AnalyticsNav'
|
||||
|
||||
function StatCard({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
||||
<p className="mt-3 text-3xl font-bold text-white">{Number(value || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsFunnel({ nav = [], range, summary = {}, bestConverters = [] }) {
|
||||
return (
|
||||
<AdminLayout title="Academy Funnel" subtitle="Early conversion signals from premium previews, upgrade clicks, and learning starts.">
|
||||
<Head title="Admin · Academy Funnel" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<AnalyticsNav items={nav} />
|
||||
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Range</p>
|
||||
<p className="mt-3 text-sm text-slate-300">{range?.from} to {range?.to}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Academy Visitors" value={summary.academyVisitors} />
|
||||
<StatCard label="Premium Preview Views" value={summary.premiumPreviewViews} />
|
||||
<StatCard label="Upgrade Clicks" value={summary.upgradeClicks} />
|
||||
<StatCard label="Learning Starts" value={summary.starts} />
|
||||
<StatCard label="Completions" value={summary.completions} />
|
||||
<StatCard label="Checkout Starts" value={summary.checkoutStarts} />
|
||||
<StatCard label="Subscriptions" value={summary.subscriptions} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Best Converting Content</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{bestConverters.length ? bestConverters.map((item) => (
|
||||
<div key={`${item.content_type}-${item.content_id || 'none'}`} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-white">{item.title}</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{item.content_type_label}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-sky-100">{item.conversion_score}</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">conversion</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400">No conversion signals have been rolled up yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
307
resources/js/Pages/Admin/Academy/AnalyticsIntelligence.jsx
Normal file
307
resources/js/Pages/Admin/Academy/AnalyticsIntelligence.jsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Head, router } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import AnalyticsNav from './AnalyticsNav'
|
||||
|
||||
function SummaryCard({ label, value, description }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.04] p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
||||
<p className="mt-3 text-3xl font-bold text-white">{Number(value || 0).toLocaleString()}</p>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RangeControls({ range }) {
|
||||
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
||||
const [from, setFrom] = useState(range?.from || '')
|
||||
const [to, setTo] = useState(range?.to || '')
|
||||
|
||||
const visit = (nextRange, nextFrom = from, nextTo = to) => {
|
||||
router.get(pathname, {
|
||||
range: nextRange,
|
||||
...(nextRange === 'custom' ? { from: nextFrom, to: nextTo } : {}),
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Date Range</p>
|
||||
<p className="mt-3 text-sm text-slate-300">{range?.from} to {range?.to}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(range?.options || []).map((option) => {
|
||||
const active = option.value === range?.active
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => visit(option.value)}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${active ? 'border-sky-300/30 bg-sky-300/12 text-sky-100' : 'border-white/[0.08] bg-white/[0.04] text-slate-300 hover:border-white/15 hover:bg-white/[0.06] hover:text-white'}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap items-end gap-3 border-t border-white/[0.08] pt-5">
|
||||
<label className="flex flex-col gap-2 text-sm text-slate-300">
|
||||
<span>From</span>
|
||||
<input type="date" value={from} onChange={(event) => setFrom(event.target.value)} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-3 text-white outline-none transition focus:border-sky-300/30" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-2 text-sm text-slate-300">
|
||||
<span>To</span>
|
||||
<input type="date" value={to} onChange={(event) => setTo(event.target.value)} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-3 text-white outline-none transition focus:border-sky-300/30" />
|
||||
</label>
|
||||
<button type="button" onClick={() => visit('custom', from, to)} className="rounded-2xl border border-white/[0.08] bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/15 hover:bg-white/[0.06]">
|
||||
Apply Custom Range
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, description, children }) {
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{title}</p>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-slate-300">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ text }) {
|
||||
return <div className="rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-8 text-sm text-slate-400">{text}</div>
|
||||
}
|
||||
|
||||
function Badge({ children, tone = 'default' }) {
|
||||
const tones = {
|
||||
default: 'border-white/[0.08] bg-white/[0.04] text-slate-200',
|
||||
high: 'border-rose-300/25 bg-rose-300/10 text-rose-100',
|
||||
medium: 'border-amber-300/25 bg-amber-300/10 text-amber-100',
|
||||
low: 'border-emerald-300/25 bg-emerald-300/10 text-emerald-100',
|
||||
}
|
||||
|
||||
return <span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tones[tone] || tones.default}`}>{children}</span>
|
||||
}
|
||||
|
||||
function Table({ columns, children }) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-[24px] border border-white/[0.08] bg-black/20">
|
||||
<table className="min-w-full divide-y divide-white/[0.08] text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column} className="px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{column}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.06]">{children}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpportunityHighlights({ items = [] }) {
|
||||
if (!items.length) {
|
||||
return <EmptyState text="Recommendations will appear here once Academy analytics has enough activity in the selected range." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={`${item.title}-${index}`} className="rounded-[24px] border border-white/[0.08] bg-black/20 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-base font-semibold text-white">{item.title}</p>
|
||||
<Badge tone={item.priority}>{item.priority}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">{item.reason}</p>
|
||||
<p className="mt-4 text-sm font-semibold text-sky-100">{item.suggested_action}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsIntelligence({
|
||||
nav = [],
|
||||
range,
|
||||
contentOpportunities = {},
|
||||
searchGaps = {},
|
||||
promptInsights = {},
|
||||
lessonDropoffs = {},
|
||||
courseHealth = {},
|
||||
premiumInterest = {},
|
||||
editorialRecommendations = {},
|
||||
}) {
|
||||
return (
|
||||
<AdminLayout title="Academy Content Intelligence" subtitle="Editorial and business signals from Academy rollups, search demand, engagement, and premium intent.">
|
||||
<Head title="Admin · Academy Content Intelligence" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<AnalyticsNav items={nav} />
|
||||
<RangeControls range={range} />
|
||||
|
||||
<Section title="Content Opportunities" description="A fast view of where Academy demand is strongest, where content is underperforming, and which changes should be prioritized next.">
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-7">
|
||||
{(contentOpportunities?.cards || []).map((card) => (
|
||||
<SummaryCard key={card.label} label={card.label} value={card.value} description={card.description} />
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<OpportunityHighlights items={contentOpportunities?.highlights || []} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Search Gaps" description="Queries that suggest missing content, weak relevance, or topics worth expanding because users are clearly engaging with them.">
|
||||
{searchGaps?.rows?.length ? (
|
||||
<Table columns={['Query', 'Searches', 'Results', 'Clicks', 'CTR', 'Suggested Action']}>
|
||||
{searchGaps.rows.map((row) => (
|
||||
<tr key={row.normalized_query}>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<p className="font-semibold text-white">{row.query}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Badge tone={row.priority}>{row.issue}</Badge>
|
||||
{row.logged_in_searches > 1 ? <Badge>Logged-in x{row.logged_in_searches}</Badge> : null}
|
||||
{row.subscriber_searches > 0 ? <Badge>Subscribers x{row.subscriber_searches}</Badge> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.searches}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.results_count}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.clicks}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.ctr}%</td>
|
||||
<td className="px-4 py-4 text-sm leading-6 text-slate-300">{row.suggested_action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</Table>
|
||||
) : <EmptyState text="No Academy search gaps were detected in this range." />}
|
||||
</Section>
|
||||
|
||||
<Section title="Prompt Insights" description="Signals that show whether prompts need better quality, stronger discoverability, more examples, or a premium follow-up.">
|
||||
{promptInsights?.rows?.length ? (
|
||||
<Table columns={['Prompt', 'Views', 'Copies', 'Copy Rate', 'Saves', 'Likes', 'Issue', 'Suggested Action']}>
|
||||
{promptInsights.rows.map((row) => (
|
||||
<tr key={row.content_id}>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<p className="font-semibold text-white">{row.title}</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-slate-500">{row.content_type_label}</p>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.views}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.prompt_copies}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.copy_rate}%</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.saves}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.likes}</td>
|
||||
<td className="px-4 py-4"><Badge tone={row.priority}>{row.issue}</Badge></td>
|
||||
<td className="px-4 py-4 text-sm leading-6 text-slate-300">{row.suggested_action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</Table>
|
||||
) : <EmptyState text="No prompt intelligence signals were detected in this range." />}
|
||||
</Section>
|
||||
|
||||
<Section title="Lesson Drop-offs" description="Lessons where users hesitate to start, fail to finish, or unexpectedly show strong premium interest.">
|
||||
{lessonDropoffs?.rows?.length ? (
|
||||
<Table columns={['Lesson', 'Views', 'Starts', 'Completions', 'Completion Rate', 'Issue', 'Suggested Action']}>
|
||||
{lessonDropoffs.rows.map((row) => (
|
||||
<tr key={row.content_id}>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<p className="font-semibold text-white">{row.title}</p>
|
||||
<p className="mt-2 text-xs uppercase tracking-[0.18em] text-slate-500">Start rate {row.start_rate}%</p>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.views}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.starts}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.completions}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.completion_rate}%</td>
|
||||
<td className="px-4 py-4"><Badge tone={row.priority}>{row.issue}</Badge></td>
|
||||
<td className="px-4 py-4 text-sm leading-6 text-slate-300">{row.suggested_action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</Table>
|
||||
) : <EmptyState text="No lesson drop-off signals were detected in this range." />}
|
||||
</Section>
|
||||
|
||||
<Section title="Course Health" description="Courses that need better positioning or restructuring, plus courses that have enough momentum to justify expansion.">
|
||||
{courseHealth?.rows?.length ? (
|
||||
<Table columns={['Course', 'Views', 'Starts', 'Completions', 'Completion Rate', 'Avg Progress', 'Suggested Action']}>
|
||||
{courseHealth.rows.map((row) => (
|
||||
<tr key={row.content_id}>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<p className="font-semibold text-white">{row.title}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Badge tone={row.priority}>{row.issue}</Badge>
|
||||
{row.learners > 0 ? <Badge>Learners {row.learners}</Badge> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.views}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.starts}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.completions}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.completion_rate}%</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.avg_progress}%</td>
|
||||
<td className="px-4 py-4 text-sm leading-6 text-slate-300">{row.suggested_action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</Table>
|
||||
) : <EmptyState text="No course health signals were detected in this range." />}
|
||||
</Section>
|
||||
|
||||
<Section title="Premium Interest" description="Free and premium Academy content that either converts well into upgrade intent or needs stronger teaser positioning.">
|
||||
{premiumInterest?.rows?.length ? (
|
||||
<Table columns={['Content', 'Type', 'Premium Views', 'Upgrade Clicks', 'Upgrade Rate', 'Suggested Action']}>
|
||||
{premiumInterest.rows.map((row) => (
|
||||
<tr key={`${row.content_type}-${row.content_id}`}>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<p className="font-semibold text-white">{row.title}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Badge tone={row.priority}>{row.issue}</Badge>
|
||||
<Badge>Interest score {row.premium_interest_score}</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.content_type_label}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.premium_preview_views}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.upgrade_clicks}</td>
|
||||
<td className="px-4 py-4 text-sm text-slate-200">{row.upgrade_rate}%</td>
|
||||
<td className="px-4 py-4 text-sm leading-6 text-slate-300">{row.suggested_action}</td>
|
||||
</tr>
|
||||
))}
|
||||
</Table>
|
||||
) : <EmptyState text="No premium interest signals were detected in this range." />}
|
||||
</Section>
|
||||
|
||||
<Section title="Editorial Recommendations" description="Prioritized recommendations that combine content demand, user behavior, and premium intent into concrete next actions.">
|
||||
{editorialRecommendations?.rows?.length ? (
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{editorialRecommendations.rows.map((row, index) => (
|
||||
<div key={`${row.title}-${index}`} className="rounded-[24px] border border-white/[0.08] bg-black/20 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-base font-semibold text-white">{row.title}</p>
|
||||
<Badge tone={row.priority}>{row.priority}</Badge>
|
||||
</div>
|
||||
<p className="mt-3 text-sm leading-6 text-slate-300">{row.description}</p>
|
||||
<p className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Reason</p>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">{row.reason}</p>
|
||||
<p className="mt-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Suggested Action</p>
|
||||
<p className="mt-2 text-sm font-semibold leading-6 text-sky-100">{row.suggested_action}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <EmptyState text="No editorial recommendations were generated for this range yet." />}
|
||||
</Section>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
26
resources/js/Pages/Admin/Academy/AnalyticsNav.jsx
Normal file
26
resources/js/Pages/Admin/Academy/AnalyticsNav.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { Link } from '@inertiajs/react'
|
||||
|
||||
export default function AnalyticsNav({ items = [] }) {
|
||||
if (!items.length) return null
|
||||
|
||||
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.href
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-300/12 text-sky-100' : 'border-white/[0.08] bg-white/[0.04] text-slate-300 hover:border-white/15 hover:bg-white/[0.06] hover:text-white'}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
resources/js/Pages/Admin/Academy/AnalyticsOverview.jsx
Normal file
73
resources/js/Pages/Admin/Academy/AnalyticsOverview.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React from 'react'
|
||||
import { Head } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import AnalyticsNav from './AnalyticsNav'
|
||||
|
||||
function StatCard({ label, value }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
||||
<p className="mt-3 text-3xl font-bold text-white">{Number(value || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentList({ title, items = [] }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{title}</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.length ? items.map((item) => (
|
||||
<div key={`${item.content_type}-${item.content_id || 'none'}`} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{item.title}</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{item.content_type_label}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-sky-100">{item.popularity_score}</p>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-500">popularity</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400">No rollup data yet for this range.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsOverview({ nav = [], range, stats, topContent = [], topWeek = [] }) {
|
||||
return (
|
||||
<AdminLayout title="Academy Analytics" subtitle="Daily rollup overview for Academy traffic, engagement, and subscription intent.">
|
||||
<Head title="Admin · Academy Analytics" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<AnalyticsNav items={nav} />
|
||||
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Range</p>
|
||||
<p className="mt-3 text-sm text-slate-300">{range?.from} to {range?.to}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Views" value={stats.views} />
|
||||
<StatCard label="Unique Visitors" value={stats.uniqueVisitors} />
|
||||
<StatCard label="Logged-in Views" value={stats.userViews} />
|
||||
<StatCard label="Guest Views" value={stats.guestViews} />
|
||||
<StatCard label="Subscriber Views" value={stats.subscriberViews} />
|
||||
<StatCard label="Prompt Copies" value={stats.promptCopies} />
|
||||
<StatCard label="Likes" value={stats.likes} />
|
||||
<StatCard label="Saves" value={stats.saves} />
|
||||
<StatCard label="Lesson Completions" value={stats.lessonCompletions} />
|
||||
<StatCard label="Course Starts" value={stats.courseStarts} />
|
||||
<StatCard label="Upgrade Clicks" value={stats.upgradeClicks} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<ContentList title="Top Content In Range" items={topContent} />
|
||||
<ContentList title="Top Content This Week" items={topWeek} />
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
109
resources/js/Pages/Admin/Academy/AnalyticsSearch.jsx
Normal file
109
resources/js/Pages/Admin/Academy/AnalyticsSearch.jsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import React from 'react'
|
||||
import { Head } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
import AnalyticsNav from './AnalyticsNav'
|
||||
|
||||
function SearchList({ title, items = [], emptyText }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{title}</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.length ? items.map((item, index) => (
|
||||
<div key={`${item.query}-${index}`} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="font-semibold text-white">{item.query}</p>
|
||||
{'searches' in item ? <p className="text-sm font-semibold text-sky-100">{item.searches}</p> : null}
|
||||
</div>
|
||||
{'avg_results' in item ? <p className="mt-2 text-sm text-slate-300">Average results: {item.avg_results}</p> : null}
|
||||
{'clicks' in item ? <p className="mt-2 text-sm text-slate-300">Clicks: {item.clicks}</p> : null}
|
||||
{'click_through_rate' in item ? <p className="mt-2 text-sm text-slate-300">CTR: {item.click_through_rate}%</p> : null}
|
||||
{'results_count' in item ? <p className="mt-2 text-sm text-slate-300">Results: {item.results_count}</p> : null}
|
||||
</div>
|
||||
)) : <p className="rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400">{emptyText}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterUsageList({ items = [] }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Filter Usage</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.length ? items.map((item) => (
|
||||
<div key={`${item.filter}-${item.value}`} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="font-semibold text-white">{item.filter}: {item.value}</p>
|
||||
<p className="text-sm font-semibold text-sky-100">{item.uses}</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400">No Academy search filters were used in this range.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClickedResultsList({ items = [] }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Top Clicked Results</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{items.length ? items.map((item) => (
|
||||
<div key={`${item.content_type}-${item.content_id}`} className="rounded-2xl border border-white/[0.08] bg-black/20 px-4 py-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="font-semibold text-white">{item.title}</p>
|
||||
<p className="text-sm font-semibold text-sky-100">{item.clicks}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-300">{item.content_type}</p>
|
||||
</div>
|
||||
)) : <p className="rounded-2xl border border-dashed border-white/[0.08] bg-black/20 px-4 py-6 text-sm text-slate-400">No clicked Academy search results were logged in this range.</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyAnalyticsSearch({ nav = [], range, summary = {}, topSearches = [], zeroResults = [], lowClickThroughSearches = [], highestClickThroughSearches = [], searchesWithResultsNoClicks = [], topClickedResults = [], filterUsage = [], recentSearches = [] }) {
|
||||
return (
|
||||
<AdminLayout title="Academy Search Analytics" subtitle="Search demand, zero-result gaps, and recent Academy query activity.">
|
||||
<Head title="Admin · Academy Search Analytics" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<AnalyticsNav items={nav} />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5"><p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Searches</p><p className="mt-3 text-3xl font-bold text-white">{Number(summary.searches || 0).toLocaleString()}</p></div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5"><p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Zero Result Searches</p><p className="mt-3 text-3xl font-bold text-white">{Number(summary.zeroResultSearches || 0).toLocaleString()}</p></div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5"><p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Logged-in Searches</p><p className="mt-3 text-3xl font-bold text-white">{Number(summary.loggedInSearches || 0).toLocaleString()}</p></div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5"><p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Subscriber Searches</p><p className="mt-3 text-3xl font-bold text-white">{Number(summary.subscriberSearches || 0).toLocaleString()}</p></div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5"><p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Searches With Clicks</p><p className="mt-3 text-3xl font-bold text-white">{Number(summary.searchesWithClicks || 0).toLocaleString()}</p></div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5"><p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Needs CTR Tracking</p><p className="mt-3 text-sm leading-6 text-slate-300">Low-click sections use stored search click attribution when present. Queries without clicked-result updates will stay at 0% CTR until that interaction is sent.</p></div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Range</p>
|
||||
<p className="mt-3 text-sm text-slate-300">{range?.from} to {range?.to}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<SearchList title="Top Searches" items={topSearches} emptyText="No Academy searches were logged in this range." />
|
||||
<SearchList title="Highest CTR Searches" items={highestClickThroughSearches} emptyText="No clicked Academy searches were logged in this range." />
|
||||
<SearchList title="Zero-result Searches" items={zeroResults} emptyText="No zero-result searches were logged in this range." />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<SearchList title="Low Click-through Searches" items={lowClickThroughSearches} emptyText="No low click-through Academy searches were logged in this range." />
|
||||
<SearchList title="Results With No Clicks" items={searchesWithResultsNoClicks} emptyText="No Academy searches with results but no clicks were logged in this range." />
|
||||
<ClickedResultsList items={topClickedResults} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<FilterUsageList items={filterUsage} />
|
||||
<SearchList title="Recent Searches" items={recentSearches} emptyText="No recent Academy searches were logged in this range." />
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
206
resources/js/Pages/Admin/Academy/Billing.jsx
Normal file
206
resources/js/Pages/Admin/Academy/Billing.jsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
function StatCard({ label, value, hint = null }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-white/[0.04] p-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
||||
<p className="mt-3 text-3xl font-bold text-white">{value.toLocaleString()}</p>
|
||||
{hint ? <p className="mt-2 text-sm text-slate-400">{hint}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) return 'No webhook processed yet'
|
||||
|
||||
try {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value))
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function formatEventSummary(summary) {
|
||||
const payload = summary && typeof summary === 'object' ? summary : {}
|
||||
const preferredKeys = [
|
||||
'action',
|
||||
'outcome',
|
||||
'local_subscription_status',
|
||||
'status',
|
||||
'tracked',
|
||||
'user_resolved',
|
||||
]
|
||||
|
||||
const prioritized = preferredKeys
|
||||
.filter((key) => Object.prototype.hasOwnProperty.call(payload, key))
|
||||
.map((key) => [key, payload[key]])
|
||||
|
||||
const priceIds = Array.isArray(payload.price_ids) && payload.price_ids.length
|
||||
? [['price_ids', payload.price_ids.join(', ')]]
|
||||
: []
|
||||
|
||||
const cacheCleared = typeof payload.cache_cleared === 'boolean'
|
||||
? [['cache_cleared', payload.cache_cleared ? 'yes' : 'no']]
|
||||
: []
|
||||
|
||||
const lines = [...prioritized, ...priceIds, ...cacheCleared]
|
||||
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||
.slice(0, 4)
|
||||
|
||||
return lines.length
|
||||
? lines.map(([key, value]) => `${key}: ${String(value)}`).join(' · ')
|
||||
: 'No summary fields captured'
|
||||
}
|
||||
|
||||
export default function AcademyBilling({ summary, planBreakdown, recentEvents, links }) {
|
||||
const missingPlans = Array.isArray(summary.missing_plan_keys) ? summary.missing_plan_keys : []
|
||||
const noData =
|
||||
summary.enabled &&
|
||||
(summary.active_subscribers || 0) === 0 &&
|
||||
(summary.ended_subscriptions || 0) === 0 &&
|
||||
(summary.recent_webhook_count || 0) === 0
|
||||
|
||||
return (
|
||||
<AdminLayout title="Academy Billing" subtitle="Moderation overview of Academy subscriptions, Stripe webhook sync activity, and plan readiness.">
|
||||
<Head title="Admin · Academy Billing" />
|
||||
|
||||
{noData ? (
|
||||
<div className="mb-6 rounded-2xl border border-sky-300/20 bg-sky-300/[0.06] px-5 py-4 text-sm text-sky-100">
|
||||
<p className="font-semibold">No subscriber data in the database yet.</p>
|
||||
<p className="mt-1 text-sky-100/70">
|
||||
Subscription records are created when Stripe sends webhook events to this server after a completed checkout. In local development, use{' '}
|
||||
<code className="rounded bg-black/30 px-1.5 py-0.5 font-mono text-xs">stripe listen --forward-to {window.location.origin}/stripe/webhook</code>{' '}
|
||||
to forward events. On production, confirm the Stripe webhook is configured and active.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatCard label="Active Subscribers" value={summary.active_subscribers || 0} />
|
||||
<StatCard label="Creator Subscribers" value={summary.creator_subscribers || 0} />
|
||||
<StatCard label="Pro Subscribers" value={summary.pro_subscribers || 0} />
|
||||
<StatCard label="Grace Period" value={summary.grace_period_subscribers || 0} hint="Canceled subscriptions that still keep access until the billing period ends." />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Plan Health</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Configured Academy plans</h2>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href={links.dashboard} className="rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200 transition hover:border-white/15 hover:bg-white/[0.06] hover:text-white">
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link href={links.pricing} className="rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/30 hover:bg-sky-300/15">
|
||||
Public pricing
|
||||
</Link>
|
||||
<Link href={links.account} className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100 transition hover:border-emerald-300/30 hover:bg-emerald-300/15">
|
||||
My billing account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{missingPlans.length ? (
|
||||
<div className="mt-5 rounded-2xl border border-amber-300/25 bg-amber-300/10 px-4 py-3 text-sm text-amber-100">
|
||||
Missing Stripe price IDs for: {missingPlans.join(', ')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-5 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">
|
||||
All configured Academy plans have Stripe price IDs.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5 grid gap-3 md:grid-cols-2">
|
||||
{planBreakdown.map((plan) => (
|
||||
<div key={plan.key} className="rounded-2xl border border-white/[0.08] bg-black/20 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-base font-semibold text-white">{plan.label}</p>
|
||||
<p className="mt-1 text-sm text-slate-400">{plan.tier} · {plan.interval}</p>
|
||||
</div>
|
||||
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.18em] ${plan.configured ? 'bg-emerald-300/12 text-emerald-100' : 'bg-amber-300/12 text-amber-100'}`}>
|
||||
{plan.configured ? 'configured' : 'missing'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-4 text-3xl font-bold text-white">{(plan.subscribers || 0).toLocaleString()}</p>
|
||||
<p className="mt-1 text-sm text-slate-400">active subscriptions on this plan</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Webhook Sync</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Recent Stripe activity</h2>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-black/20 p-4">
|
||||
<p className="text-sm text-slate-400">Billing enabled</p>
|
||||
<p className="mt-1 text-lg font-semibold text-white">{summary.enabled ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-black/20 p-4">
|
||||
<p className="text-sm text-slate-400">Webhook audits stored</p>
|
||||
<p className="mt-1 text-lg font-semibold text-white">{(summary.recent_webhook_count || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-black/20 p-4">
|
||||
<p className="text-sm text-slate-400">Last processed webhook</p>
|
||||
<p className="mt-1 text-lg font-semibold text-white">{formatTimestamp(summary.last_webhook_at)}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/[0.08] bg-black/20 p-4">
|
||||
<p className="text-sm text-slate-400">Ended subscriptions</p>
|
||||
<p className="mt-1 text-lg font-semibold text-white">{(summary.ended_subscriptions || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="mt-8 rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Audit Trail</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">Latest academy billing events</h2>
|
||||
</div>
|
||||
<p className="text-sm text-slate-400">Only the safe local summary is stored, not the raw Stripe payload.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-white/[0.08] text-sm text-slate-300">
|
||||
<thead>
|
||||
<tr className="text-left text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">
|
||||
<th className="px-3 py-3">Event</th>
|
||||
<th className="px-3 py-3">Plan</th>
|
||||
<th className="px-3 py-3">Tier</th>
|
||||
<th className="px-3 py-3">User</th>
|
||||
<th className="px-3 py-3">Processed</th>
|
||||
<th className="px-3 py-3">Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.06]">
|
||||
{recentEvents.length ? recentEvents.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td className="px-3 py-3 font-medium text-white">{event.event_type}</td>
|
||||
<td className="px-3 py-3">{event.academy_plan || 'n/a'}</td>
|
||||
<td className="px-3 py-3">{event.academy_tier || 'n/a'}</td>
|
||||
<td className="px-3 py-3">{event.user_id || 'guest/unresolved'}</td>
|
||||
<td className="px-3 py-3">{formatTimestamp(event.processed_at || event.created_at)}</td>
|
||||
<td className="px-3 py-3 text-slate-400">{formatEventSummary(event.payload_summary)}</td>
|
||||
</tr>
|
||||
)) : (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-3 py-6 text-center text-slate-400">No Academy billing webhook audits have been stored yet.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,18 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
import AdminLayout from '../../../Layouts/AdminLayout'
|
||||
|
||||
const PROMPT_VIEW_STORAGE_KEY = 'skinbase.admin.academy.prompts.view'
|
||||
const COURSE_VIEW_STORAGE_KEY = 'skinbase.admin.academy.courses.view'
|
||||
const PROMPT_VIEW_OPTIONS = [
|
||||
{ value: 'gallery', label: 'Gallery', icon: 'fa-images' },
|
||||
{ value: 'grid', label: 'Grid', icon: 'fa-grid-2' },
|
||||
{ value: 'table', label: 'Table', icon: 'fa-table-list' },
|
||||
]
|
||||
const COURSE_VIEW_OPTIONS = [
|
||||
{ value: 'grid', label: 'Grid', icon: 'fa-grid-2' },
|
||||
{ value: 'table', label: 'Table', icon: 'fa-table-list' },
|
||||
]
|
||||
|
||||
function formatDateLabel(value) {
|
||||
if (!value) return 'Recently updated'
|
||||
@@ -27,6 +32,58 @@ function paginationLabel(label) {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function courseStatusMeta(status) {
|
||||
const normalized = String(status || 'draft')
|
||||
|
||||
if (normalized === 'published') {
|
||||
return { label: 'Published', className: 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100' }
|
||||
}
|
||||
|
||||
if (normalized === 'review') {
|
||||
return { label: 'Review', className: 'border-amber-300/20 bg-amber-300/10 text-amber-100' }
|
||||
}
|
||||
|
||||
if (normalized === 'archived') {
|
||||
return { label: 'Archived', className: 'border-white/10 bg-white/[0.04] text-slate-300' }
|
||||
}
|
||||
|
||||
return { label: 'Draft', className: 'border-slate-500/20 bg-slate-500/10 text-slate-300' }
|
||||
}
|
||||
|
||||
function courseAccessMeta(accessLevel) {
|
||||
const normalized = String(accessLevel || 'free')
|
||||
|
||||
if (normalized === 'premium') {
|
||||
return { label: 'Premium', className: 'border-[#ffcfbf]/20 bg-[#ffcfbf]/10 text-[#fff0ea]' }
|
||||
}
|
||||
|
||||
if (normalized === 'mixed') {
|
||||
return { label: 'Mixed', className: 'border-sky-300/20 bg-sky-300/10 text-sky-100' }
|
||||
}
|
||||
|
||||
return { label: 'Free', className: 'border-white/10 bg-white/[0.05] text-slate-200' }
|
||||
}
|
||||
|
||||
function courseSummary(items = [], summary = null) {
|
||||
if (summary && typeof summary === 'object') {
|
||||
return {
|
||||
total: Number(summary.total || 0),
|
||||
published: Number(summary.published || 0),
|
||||
featured: Number(summary.featured || 0),
|
||||
drafts: Number(summary.drafts || 0),
|
||||
visibleOnPage: Array.isArray(items) ? items.length : 0,
|
||||
}
|
||||
}
|
||||
|
||||
return items.reduce((accumulator, item) => ({
|
||||
total: accumulator.total + 1,
|
||||
published: accumulator.published + (item.status === 'published' ? 1 : 0),
|
||||
featured: accumulator.featured + (item.is_featured ? 1 : 0),
|
||||
drafts: accumulator.drafts + (item.status === 'draft' ? 1 : 0),
|
||||
visibleOnPage: accumulator.visibleOnPage + 1,
|
||||
}), { total: 0, published: 0, featured: 0, drafts: 0, visibleOnPage: 0 })
|
||||
}
|
||||
|
||||
function promptSummary(items = []) {
|
||||
return items.reduce((summary, item) => ({
|
||||
total: summary.total + 1,
|
||||
@@ -49,12 +106,249 @@ function PromptFlag({ children, tone = 'default' }) {
|
||||
return <span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClass}`}>{children}</span>
|
||||
}
|
||||
|
||||
function CoursePill({ children, tone = 'default' }) {
|
||||
const toneClass = tone === 'warm'
|
||||
? 'border-[#ffcfbf]/20 bg-[#ffcfbf]/10 text-[#fff0ea]'
|
||||
: tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-300/10 text-sky-100'
|
||||
: tone === 'emerald'
|
||||
? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'
|
||||
: 'border-white/10 bg-white/[0.05] text-slate-200'
|
||||
|
||||
return <span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${toneClass}`}>{children}</span>
|
||||
}
|
||||
|
||||
function CourseCover({ item, compact = false }) {
|
||||
if (item.cover_image_url) {
|
||||
return <img src={item.cover_image_url} alt={item.title} className={`h-full w-full object-cover transition duration-500 ${compact ? 'group-hover:scale-[1.04]' : 'group-hover:scale-[1.03]'}`} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.18),transparent_28%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.18),transparent_24%),linear-gradient(135deg,rgba(15,23,42,0.98),rgba(30,41,59,0.94))] p-6 text-center text-slate-300">
|
||||
<div>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500">Course cover</p>
|
||||
<p className="mt-3 text-sm font-semibold text-white">No cover image attached yet</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseCoverWall({ items = [] }) {
|
||||
const images = items
|
||||
.map((item) => item?.cover_image_url)
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
|
||||
if (!images.length) {
|
||||
return (
|
||||
<div className="flex min-h-[320px] items-center justify-center rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.18),transparent_26%),linear-gradient(135deg,rgba(12,18,31,0.98),rgba(30,41,59,0.94))] px-8 text-center">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500">Course cover wall</p>
|
||||
<p className="mt-4 text-lg font-semibold text-white">Course artwork will appear here once covers are added.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-[30px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)]">
|
||||
<div className="aspect-[16/10] overflow-hidden">
|
||||
<img src={images[0]} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{images.slice(1, 4).map((image, index) => (
|
||||
<div
|
||||
key={`${image}-${index}`}
|
||||
className="aspect-square overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)]"
|
||||
>
|
||||
<img src={image} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseStatCard({ label, value, tone = 'default' }) {
|
||||
const toneClass = tone === 'sky'
|
||||
? 'border-sky-300/20 bg-sky-300/10 text-sky-100'
|
||||
: tone === 'emerald'
|
||||
? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100'
|
||||
: tone === 'warm'
|
||||
? 'border-[#ffcfbf]/20 bg-[#ffcfbf]/10 text-[#fff0ea]'
|
||||
: 'border-white/10 bg-black/20 text-slate-300'
|
||||
|
||||
return (
|
||||
<div className={`rounded-[24px] border px-5 py-4 ${toneClass}`}>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">{label}</p>
|
||||
<p className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white">{value}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptActions({ item }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{item.preview_url ? <Link href={item.preview_url} className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-4 py-2 text-sm font-semibold text-[#fff0ea]">Preview</Link> : null}
|
||||
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Edit</Link>
|
||||
<button type="button" onClick={() => { if (!window.confirm('Delete this prompt?')) return; router.delete(item.destroy_url, { preserveScroll: true }) }} className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100">Delete</button>
|
||||
{item.preview_url ? <Link href={item.preview_url} className="inline-flex items-center gap-2 rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-4 py-2 text-sm font-semibold text-[#fff0ea]"><i className="fa-solid fa-eye text-xs" />Preview</Link> : null}
|
||||
<Link href={item.edit_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white"><i className="fa-solid fa-pen-to-square text-xs" />Edit</Link>
|
||||
<button type="button" onClick={() => { if (!window.confirm('Delete this prompt?')) return; router.delete(item.destroy_url, { preserveScroll: true }) }} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100"><i className="fa-solid fa-trash text-xs" />Delete</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseActions({ item }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={item.builder_url} className="inline-flex items-center gap-2 rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-4 py-2 text-sm font-semibold text-[#fff0ea]"><i className="fa-solid fa-sitemap text-xs" />Builder</Link>
|
||||
<Link href={item.edit_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white"><i className="fa-solid fa-pen-to-square text-xs" />Edit</Link>
|
||||
<button type="button" onClick={() => { if (!window.confirm('Delete this course?')) return; router.delete(item.destroy_url, { preserveScroll: true }) }} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100"><i className="fa-solid fa-trash text-xs" />Delete</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseGridCard({ item }) {
|
||||
const status = courseStatusMeta(item.status)
|
||||
const access = courseAccessMeta(item.access_level)
|
||||
|
||||
return (
|
||||
<article className="group overflow-hidden rounded-[30px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.18))] shadow-[0_18px_60px_rgba(2,6,23,0.18)]">
|
||||
<div className="relative h-56 overflow-hidden border-b border-white/10">
|
||||
<CourseCover item={item} compact />
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.02),rgba(2,6,23,0.34))]" />
|
||||
</div>
|
||||
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<CoursePill tone="warm">{item.lessons_count || 0} lessons</CoursePill>
|
||||
<CoursePill tone={item.is_featured ? 'sky' : 'default'}>{item.is_featured ? 'Featured' : 'Course'}</CoursePill>
|
||||
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${status.className}`}>{status.label}</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-4 text-xl font-semibold tracking-[-0.04em] text-white">{item.title}</h2>
|
||||
{item.subtitle ? <p className="mt-2 text-sm leading-6 text-slate-300">{item.subtitle}</p> : null}
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{item.excerpt || 'No excerpt added yet.'}</p>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between gap-3 text-sm text-slate-400">
|
||||
<span>{access.label}</span>
|
||||
<span>{formatDateLabel(item.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<CourseActions item={item} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseTable({ items }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[30px] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(2,6,23,0.92))] shadow-[0_24px_80px_rgba(2,6,23,0.22)]">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-white/10 text-left">
|
||||
<thead className="bg-white/[0.04] text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">
|
||||
<tr>
|
||||
<th className="px-5 py-4">Cover</th>
|
||||
<th className="px-5 py-4">Course</th>
|
||||
<th className="px-5 py-4">Access</th>
|
||||
<th className="px-5 py-4">Status</th>
|
||||
<th className="px-5 py-4">Lessons</th>
|
||||
<th className="px-5 py-4">Updated</th>
|
||||
<th className="px-5 py-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/10 text-sm text-slate-200">
|
||||
{items.map((item) => {
|
||||
const status = courseStatusMeta(item.status)
|
||||
const access = courseAccessMeta(item.access_level)
|
||||
|
||||
return (
|
||||
<tr key={item.id} className="align-top transition hover:bg-white/[0.03]">
|
||||
<td className="px-5 py-4">
|
||||
<div className="h-20 w-28 overflow-hidden rounded-2xl border border-white/10 bg-black/30">
|
||||
<CourseCover item={item} compact />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div>
|
||||
<p className="font-semibold text-white">{item.title}</p>
|
||||
{item.subtitle ? <p className="mt-1 max-w-md text-sm leading-6 text-slate-400">{item.subtitle}</p> : null}
|
||||
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-400">{item.excerpt || 'No excerpt added yet.'}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4"><span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold ${access.className}`}>{access.label}</span></td>
|
||||
<td className="px-5 py-4"><span className={`inline-flex rounded-full border px-3 py-1 text-xs font-semibold ${status.className}`}>{status.label}</span></td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="space-y-1 text-white">
|
||||
<p>{item.lessons_count || 0} lessons</p>
|
||||
<p>{item.is_featured ? 'Featured' : 'Standard'}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">{formatDateLabel(item.updated_at)}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link href={item.builder_url} className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-2 text-xs font-semibold text-[#fff0ea]">Builder</Link>
|
||||
<Link href={item.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white">Edit</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CourseSearchBar({ value, onChange, onSubmit, onClear, viewMode, onViewModeChange }) {
|
||||
return (
|
||||
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-4 shadow-[0_18px_50px_rgba(2,6,23,0.14)]">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1 max-w-2xl">
|
||||
<i className="fa-solid fa-magnifying-glass absolute left-3.5 top-1/2 -translate-y-1/2 text-xs text-slate-500" />
|
||||
<input
|
||||
name="search"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Search title, slug, subtitle, excerpt, or description…"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 py-3 pl-9 pr-4 text-sm text-white placeholder:text-slate-600 focus:border-white/20 focus:outline-none focus:ring-1 focus:ring-white/10"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="rounded-2xl bg-sky-300/12 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/16">
|
||||
Search
|
||||
</button>
|
||||
{value ? (
|
||||
<button type="button" onClick={onClear} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white/80 transition hover:bg-white/[0.08]">
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{COURSE_VIEW_OPTIONS.map((option) => {
|
||||
const active = option.value === viewMode
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => onViewModeChange(option.value)}
|
||||
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-semibold transition ${active ? 'border-sky-300/25 bg-sky-300/12 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200 hover:border-white/20 hover:bg-white/[0.07]'}`}
|
||||
>
|
||||
<i className={`fa-solid ${option.icon} text-xs`} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -225,7 +519,7 @@ function PromptHeroCollage({ items = [] }) {
|
||||
|
||||
if (!images.length) {
|
||||
return (
|
||||
<div className="flex min-h-[420px] items-center justify-center rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.18),transparent_26%),linear-gradient(135deg,rgba(12,18,31,0.98),rgba(30,41,59,0.94))] px-8 text-center">
|
||||
<div className="flex min-h-[320px] items-center justify-center rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.18),transparent_26%),linear-gradient(135deg,rgba(12,18,31,0.98),rgba(30,41,59,0.94))] px-8 text-center">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-500">Prompt preview wall</p>
|
||||
<p className="mt-4 text-lg font-semibold text-white">Preview images will appear here as prompts get covers.</p>
|
||||
@@ -235,15 +529,149 @@ function PromptHeroCollage({ items = [] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid min-h-[420px] grid-cols-2 gap-3">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={`${image}-${index}`}
|
||||
className={`overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)] ${index === 0 ? 'col-span-2 aspect-[16/9]' : index === 3 ? 'aspect-[4/5]' : 'aspect-square'}`}
|
||||
>
|
||||
<img src={image} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-hidden rounded-[30px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)]">
|
||||
<div className="aspect-[16/10] overflow-hidden">
|
||||
<img src={images[0]} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{images.length > 1 ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{images.slice(1, 4).map((image, index) => (
|
||||
<div
|
||||
key={`${image}-${index}`}
|
||||
className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.2)] aspect-square"
|
||||
>
|
||||
<img src={image} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
function CourseIndexContent({ title, subtitle, items, createUrl, filters = {}, summary = {} }) {
|
||||
const { url } = usePage()
|
||||
const courses = items?.data || []
|
||||
const [viewMode, setViewMode] = useState('grid')
|
||||
const [searchValue, setSearchValue] = useState(filters.search || '')
|
||||
|
||||
useEffect(() => {
|
||||
setSearchValue(filters.search || '')
|
||||
}, [filters.search])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const storedView = window.localStorage.getItem(COURSE_VIEW_STORAGE_KEY)
|
||||
if (COURSE_VIEW_OPTIONS.some((option) => option.value === storedView)) {
|
||||
setViewMode(storedView)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(COURSE_VIEW_STORAGE_KEY, viewMode)
|
||||
}, [viewMode])
|
||||
|
||||
const stats = useMemo(() => courseSummary(courses, summary), [courses, summary])
|
||||
const currentPath = url.split('?')[0]
|
||||
const hasSearch = Boolean(searchValue.trim())
|
||||
const meta = items?.meta || {}
|
||||
|
||||
const handleSearch = (event) => {
|
||||
event.preventDefault()
|
||||
router.get(currentPath, { search: searchValue.trim() || undefined }, { preserveScroll: true, preserveState: true, replace: true })
|
||||
}
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchValue('')
|
||||
router.get(currentPath, {}, { preserveScroll: true, preserveState: true, replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="overflow-hidden rounded-[38px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_24%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] shadow-[0_28px_90px_rgba(2,6,23,0.28)]">
|
||||
<div className="grid gap-8 p-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start xl:p-10">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]">Academy moderation</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-300">Course library</span>
|
||||
</div>
|
||||
|
||||
<h2 className="mt-5 max-w-4xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl">{title}</h2>
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{subtitle} Search courses quickly, switch between grid and table views, and jump into editing with a cleaner visual overview of covers, status, and lesson counts.</p>
|
||||
|
||||
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<CourseStatCard label="Total" value={stats.total} tone="sky" />
|
||||
<CourseStatCard label="Published" value={stats.published} tone="emerald" />
|
||||
<CourseStatCard label="Featured" value={stats.featured} tone="warm" />
|
||||
<CourseStatCard label="Drafts" value={stats.drafts} />
|
||||
</div>
|
||||
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<Link href={createUrl} className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create course</Link>
|
||||
<Link href="/academy/courses" className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-book-open text-xs" />Open public courses</Link>
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-layer-group text-xs" />{meta.total || courses.length} courses in view</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:pt-2">
|
||||
<CourseCoverWall items={courses} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<CourseSearchBar
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
onSubmit={handleSearch}
|
||||
onClear={handleClearSearch}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-sm text-slate-400">
|
||||
{meta.total ? (
|
||||
<>
|
||||
Showing {meta.from || 0}-{meta.to || 0} of {meta.total} courses
|
||||
{hasSearch ? <span className="ml-2 text-sky-200">filtered by “{searchValue.trim()}”</span> : null}
|
||||
</>
|
||||
) : (
|
||||
'Manage Academy courses below. Changes clear Academy cache automatically.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={createUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create course</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{courses.length === 0 ? (
|
||||
<div className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] px-6 py-12 text-center text-slate-400">
|
||||
{hasSearch ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-lg font-semibold text-white">No courses matched your search.</p>
|
||||
<button type="button" onClick={handleClearSearch} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Clear search</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-lg font-semibold text-white">No courses exist yet.</p>
|
||||
<Link href={createUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create the first course</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : viewMode === 'table' ? (
|
||||
<CourseTable items={courses} />
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{courses.map((item) => <CourseGridCard key={item.id} item={item} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PaginationLinks links={items?.links} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -271,6 +699,41 @@ function PaginationLinks({ links = [] }) {
|
||||
)
|
||||
}
|
||||
|
||||
function renderCrudCell(column, item) {
|
||||
if (column === 'active') {
|
||||
const active = Boolean(item.active)
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] ${active ? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100' : 'border-white/10 bg-white/[0.04] text-slate-300'}`}>
|
||||
<i className={`fa-solid ${active ? 'fa-circle-check' : 'fa-circle-minus'} text-[11px]`} />
|
||||
<span>{active ? 'Active' : 'Inactive'}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (column === 'course_names') {
|
||||
const courseNames = Array.isArray(item.course_names) ? item.course_names.filter(Boolean) : []
|
||||
|
||||
if (courseNames.length === 0) {
|
||||
return <span className="text-sm text-slate-400">Not attached</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{courseNames.map((courseName) => (
|
||||
<span key={courseName} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-200">{courseName}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (column === 'course_order') {
|
||||
return <span className="text-sm text-white">{item.course_order ?? 'Not set'}</span>
|
||||
}
|
||||
|
||||
return <p className="mt-1 text-sm text-white">{String(item[column] ?? '')}</p>
|
||||
}
|
||||
|
||||
function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
const promptItems = items?.data || []
|
||||
const summary = promptSummary(promptItems)
|
||||
@@ -293,7 +756,7 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="overflow-hidden rounded-[38px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_24%),radial-gradient(circle_at_bottom_right,rgba(255,207,191,0.16),transparent_24%),linear-gradient(135deg,rgba(4,9,18,0.98),rgba(15,23,42,0.92))] shadow-[0_28px_90px_rgba(2,6,23,0.28)]">
|
||||
<div className="grid gap-8 p-6 xl:grid-cols-[minmax(0,1.08fr)_420px] xl:items-end xl:p-10">
|
||||
<div className="grid gap-8 p-6 xl:grid-cols-[minmax(0,1fr)_360px] xl:items-start xl:p-10">
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-[#ffcfbf]/20 bg-[#ffcfbf]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[#fff0ea]">Academy moderation</span>
|
||||
@@ -336,10 +799,10 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-7 flex flex-wrap gap-3">
|
||||
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create prompt</Link>
|
||||
<Link href="/academy/prompts" className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">Open public library</Link>
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">{summary.total} prompts in view</span>
|
||||
<div className="mt-7 flex flex-nowrap gap-3 overflow-x-auto pb-1">
|
||||
<Link href={createUrl} className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create prompt</Link>
|
||||
<Link href="/academy/prompts" className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-book-open text-xs" />Open public library</Link>
|
||||
<span className="inline-flex items-center gap-2 whitespace-nowrap rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-layer-group text-xs" />{summary.total} prompts in view</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@@ -362,7 +825,7 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="xl:pt-2">
|
||||
<PromptHeroCollage items={promptItems} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,8 +834,8 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<p className="text-sm text-slate-400">Manage Academy content below. Changes clear Academy cache automatically.</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/academy/prompts" className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85">View public library</Link>
|
||||
<Link href={createUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">Create prompt</Link>
|
||||
<Link href="/academy/prompts" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/85"><i className="fa-solid fa-book-open text-xs" />View public library</Link>
|
||||
<Link href={createUrl} className="inline-flex items-center gap-2 rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100"><i className="fa-solid fa-plus text-xs" />Create prompt</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -397,6 +860,9 @@ function PromptIndexContent({ title, subtitle, items, createUrl }) {
|
||||
|
||||
export default function AcademyCrudIndex({ title, subtitle, items, columns, createUrl }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
const resource = usePage().props.resource
|
||||
const filters = usePage().props.filters || {}
|
||||
const summary = usePage().props.summary || {}
|
||||
|
||||
return (
|
||||
<AdminLayout title={title} subtitle={subtitle}>
|
||||
@@ -404,7 +870,9 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
|
||||
|
||||
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||
|
||||
{usePage().props.resource === 'prompts' ? (
|
||||
{resource === 'courses' ? (
|
||||
<CourseIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} filters={filters} summary={summary} />
|
||||
) : resource === 'prompts' ? (
|
||||
<PromptIndexContent title={title} subtitle={subtitle} items={items} createUrl={createUrl} />
|
||||
) : (
|
||||
<>
|
||||
@@ -420,11 +888,11 @@ export default function AcademyCrudIndex({ title, subtitle, items, columns, crea
|
||||
{items.data.map((item) => (
|
||||
<div key={item.id} className="rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-5">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center">
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-6">
|
||||
{columns.map((column) => (
|
||||
<div key={column}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{column.replaceAll('_', ' ')}</p>
|
||||
<p className="mt-1 text-sm text-white">{String(item[column] ?? '')}</p>
|
||||
<div className="mt-1">{renderCrudCell(column, item)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ function StatCard({ label, value }) {
|
||||
|
||||
export default function AcademyDashboard({ stats, links }) {
|
||||
return (
|
||||
<AdminLayout title="Academy Dashboard" subtitle="Overview of Academy content, challenge activity, and future billing placeholders.">
|
||||
<AdminLayout title="Academy Dashboard" subtitle="Overview of Academy content, challenge activity, and live Academy subscription health.">
|
||||
<Head title="Admin · Academy Dashboard" />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
@@ -24,8 +24,10 @@ export default function AcademyDashboard({ stats, links }) {
|
||||
<StatCard label="Challenges" value={stats.challenges} />
|
||||
<StatCard label="Submissions" value={stats.submissions} />
|
||||
<StatCard label="Badges" value={stats.badges} />
|
||||
<StatCard label="Active Subscribers" value={stats.active_subscribers || 0} />
|
||||
<StatCard label="Creator Subscribers" value={stats.creator_subscribers} />
|
||||
<StatCard label="Pro Subscribers" value={stats.pro_subscribers} />
|
||||
<StatCard label="Grace Period" value={stats.grace_period_subscribers || 0} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-[28px] border border-white/[0.08] bg-white/[0.03] p-6">
|
||||
|
||||
@@ -7,6 +7,7 @@ import RichTextEditor from '../../../components/forum/RichTextEditor'
|
||||
import WorldMediaUploadField from '../../../components/worlds/editor/WorldMediaUploadField'
|
||||
import DateTimePicker from '../../../components/ui/DateTimePicker'
|
||||
import NovaSelect from '../../../components/ui/NovaSelect'
|
||||
import ShareToast from '../../../components/ui/ShareToast'
|
||||
|
||||
let lessonMarkdownTurndown = null
|
||||
let lessonMarkdownTurndownPromise = null
|
||||
@@ -74,9 +75,9 @@ const LESSON_EDITOR_TABS = [
|
||||
{
|
||||
id: 'assets',
|
||||
label: 'Assets',
|
||||
description: 'Categories, hero media, and article imagery.',
|
||||
description: 'Hero cover, article cover, and lesson categories.',
|
||||
icon: 'fa-images',
|
||||
sections: ['lesson-categories', 'lesson-cover', 'lesson-article-cover'],
|
||||
sections: ['lesson-cover', 'lesson-article-cover', 'lesson-categories'],
|
||||
},
|
||||
{
|
||||
id: 'revisions',
|
||||
@@ -157,6 +158,23 @@ function FieldError({ message }) {
|
||||
return <p className="text-xs text-rose-300">{message}</p>
|
||||
}
|
||||
|
||||
function CopyablePromptCard({ eyebrow, title, description, prompt, onCopy }) {
|
||||
return (
|
||||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">{eyebrow}</p>
|
||||
<h3 className="mt-1 text-base font-semibold text-white">{title}</h3>
|
||||
{description ? <p className="mt-2 text-sm leading-6 text-slate-400">{description}</p> : null}
|
||||
</div>
|
||||
<button type="button" onClick={onCopy} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy prompt</button>
|
||||
</div>
|
||||
|
||||
<textarea readOnly value={prompt} rows={10} spellCheck={false} className="mt-4 w-full rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300 outline-none" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionCard({ id, eyebrow, title, description, actions, children, tone = 'default', className = '', contentClassName = '' }) {
|
||||
const toneClass = tone === 'feature'
|
||||
? 'bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_38%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] shadow-[0_24px_70px_rgba(2,6,23,0.28)]'
|
||||
@@ -241,6 +259,35 @@ function lessonTabErrorCounts(errors) {
|
||||
return counts
|
||||
}
|
||||
|
||||
function firstErrorMessage(errors, fallback = 'Please correct the highlighted fields and try again.') {
|
||||
const queue = [errors]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()
|
||||
|
||||
if (typeof current === 'string') {
|
||||
const message = current.trim()
|
||||
|
||||
if (message) {
|
||||
return message
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(current)) {
|
||||
queue.push(...current)
|
||||
continue
|
||||
}
|
||||
|
||||
if (current && typeof current === 'object') {
|
||||
queue.push(...Object.values(current))
|
||||
}
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function TextField({ label, value, onChange, error, hint, ...rest }) {
|
||||
return (
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
@@ -652,8 +699,143 @@ function parseLessonImport(rawText, categoryOptions) {
|
||||
return { next, applied }
|
||||
}
|
||||
|
||||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
|
||||
function buildLessonImportExample({ title, excerpt, difficulty, accessLevel, lessonType, categoryName }) {
|
||||
const nextTitle = String(title || '').trim() || 'How to Build Cleaner Prompt References'
|
||||
const nextExcerpt = String(excerpt || '').trim() || 'Build a lesson draft with a clear promise, practical steps, and reusable examples.'
|
||||
|
||||
return JSON.stringify({
|
||||
title: nextTitle,
|
||||
slug: slugifyLessonTitle(nextTitle),
|
||||
excerpt: nextExcerpt,
|
||||
category: String(categoryName || '').trim() || 'Prompting',
|
||||
difficulty: String(difficulty || '').trim() || 'beginner',
|
||||
access_level: String(accessLevel || '').trim() || 'free',
|
||||
lesson_type: String(lessonType || '').trim() || 'article',
|
||||
tags: ['prompting', 'workflow', 'editing'],
|
||||
content_markdown: [
|
||||
'# Why this lesson matters',
|
||||
'',
|
||||
'Open with the promise of the lesson and the result the reader should get.',
|
||||
'',
|
||||
'## Core workflow',
|
||||
'',
|
||||
'- Step 1: Define the goal clearly.',
|
||||
'- Step 2: Show the pattern or framework.',
|
||||
'- Step 3: Add one concrete example.',
|
||||
'',
|
||||
'## Wrap up',
|
||||
'',
|
||||
'Close with the next action or checklist the reader should follow.',
|
||||
].join('\n'),
|
||||
reading_minutes: 8,
|
||||
seo_title: nextTitle,
|
||||
seo_description: nextExcerpt,
|
||||
active: false,
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
function buildLessonImportPrompt({ title, difficulty, accessLevel, lessonType, categoryName }) {
|
||||
return [
|
||||
'Create valid JSON only for a Skinbase Academy lesson import.',
|
||||
'Do not wrap the answer in markdown fences.',
|
||||
'Return one object with this shape:',
|
||||
'{',
|
||||
' "title": "Lesson title",',
|
||||
' "slug": "lesson-title",',
|
||||
' "excerpt": "One short summary sentence.",',
|
||||
` "category": "${String(categoryName || 'Prompting')}",`,
|
||||
` "difficulty": "${String(difficulty || 'beginner')}",`,
|
||||
` "access_level": "${String(accessLevel || 'free')}",`,
|
||||
` "lesson_type": "${String(lessonType || 'article')}",`,
|
||||
' "tags": ["tag-one", "tag-two"],',
|
||||
' "content_markdown": "# Heading\\n\\nWrite the lesson body in Markdown.",',
|
||||
' "reading_minutes": 8,',
|
||||
' "seo_title": "Optional SEO title",',
|
||||
' "seo_description": "Optional SEO description",',
|
||||
' "active": false',
|
||||
'}',
|
||||
'Requirements:',
|
||||
'- Keep the response as valid JSON only.',
|
||||
'- Prefer content_markdown over HTML unless HTML is explicitly requested.',
|
||||
'- Keep excerpt concise and specific.',
|
||||
'- Keep tags short and relevant.',
|
||||
'- Use lowercase hyphenated slugs.',
|
||||
'- Do not invent image URLs unless source assets are provided.',
|
||||
`Current lesson title: ${String(title || 'Untitled lesson')}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildLessonHeroPrompt({ title, excerpt, categoryName, tags = [] }) {
|
||||
return [
|
||||
'Create a wide hero cover image for a Skinbase Academy lesson.',
|
||||
`Lesson title: ${String(title || 'Untitled lesson')}`,
|
||||
`Lesson summary: ${String(excerpt || 'No summary added yet.')}`,
|
||||
`Category: ${String(categoryName || 'Uncategorized')}`,
|
||||
`Tags: ${tags.length > 0 ? tags.join(', ') : 'none'}`,
|
||||
'',
|
||||
'Aspect ratio: 16:9 landscape.',
|
||||
'Style: cinematic editorial artwork with premium lighting, a strong focal point, and a clean composition that still reads well when cropped into cards and previews.',
|
||||
'Text rules: no added text, no captions, no logos, no watermarks, and no visible UI.',
|
||||
'Composition: keep the center readable and leave safe space for future cropping.',
|
||||
'Output: a single final image prompt, not a report.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function buildLessonArticleCoverPrompt({ courseName, lessonNumber, title, excerpt, categoryName, tags = [], aspectRatio = '3:2', mainVisualSubject, previewImageDescription }) {
|
||||
return [
|
||||
'Create a premium Skinbase Academy inline article cover image.',
|
||||
'',
|
||||
`Course name: ${String(courseName || 'Unassigned')}`,
|
||||
`Lesson number: ${String(lessonNumber || '1')}`,
|
||||
`Lesson title: ${String(title || 'Untitled lesson')}`,
|
||||
`Lesson summary: ${String(excerpt || 'No summary added yet.')}`,
|
||||
`Category: ${String(categoryName || 'Uncategorized')}`,
|
||||
`Tags: ${tags.length > 0 ? tags.join(', ') : 'none'}`,
|
||||
'',
|
||||
`Aspect ratio: ${String(aspectRatio || '3:2')}, landscape article-cover format.`,
|
||||
'',
|
||||
'Visual direction:',
|
||||
'Design a polished dark editorial academy cover inspired by a modern creative-tech learning interface. The layout should feel like a premium lesson card for an online academy article.',
|
||||
'',
|
||||
'Composition:',
|
||||
'Use a strong two-column layout.',
|
||||
'Left side: large lesson-title area, lesson badge, short summary area, and a row of small educational icon blocks.',
|
||||
'Right side: a large cinematic preview image inside a rounded rectangular frame, showing the lesson concept visually.',
|
||||
'Below or near the preview image: add a subtle prompt/workflow card with abstract lines and interface-like blocks.',
|
||||
'Bottom area: add a clean row of small learning-step modules or icon cards.',
|
||||
'',
|
||||
'Main visual subject:',
|
||||
String(mainVisualSubject || `A premium editorial visual focused on ${String(title || 'this lesson')}`),
|
||||
'',
|
||||
'The right preview image should show:',
|
||||
String(previewImageDescription || `A cinematic article-cover scene that clearly supports ${String(title || 'the lesson topic')} and feels premium at thumbnail size.`),
|
||||
'',
|
||||
'Educational UI details:',
|
||||
'Include subtle composition guide lines, crop guides, small abstract icons, prompt-card shapes, clean rounded panels, soft glows, and thin purple outlines. Make the design feel structured, modern, and readable.',
|
||||
'',
|
||||
'Style:',
|
||||
'Dark modern Skinbase Academy aesthetic, polished editorial design, premium creative-tech interface, cinematic digital art, clean hierarchy, soft shadows, rounded cards, subtle grid background, elegant purple/cyan accents, high-end course-platform look.',
|
||||
'',
|
||||
'Color palette:',
|
||||
'Deep navy, black, dark violet, purple gradients, muted cyan highlights, soft white typography areas, warm cinematic orange/gold highlights inside the preview artwork.',
|
||||
'',
|
||||
'Text handling:',
|
||||
'Use clean title-like placeholder text areas only. Do not create messy fake text. Keep typography areas visually readable and leave enough space for real text to be added later. Avoid small unreadable paragraphs.',
|
||||
'',
|
||||
'Important:',
|
||||
'No logos, no watermarks, no brand marks, no fake signatures, no cluttered UI, no distorted icons, no random letters, no overcrowded composition. The cover must work as an inline article image and still be clear at thumbnail size.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
function JsonImportDialog({ open, value, error, exampleValue, promptValue, onChange, onClose, onApply, onCopyExample, onCopyPrompt }) {
|
||||
const backdropRef = useRef(null)
|
||||
const [activeReferenceTab, setActiveReferenceTab] = useState('structure')
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveReferenceTab('structure')
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
@@ -673,7 +855,7 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
|
||||
return createPortal(
|
||||
<div
|
||||
ref={backdropRef}
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#04070dcc] px-4 backdrop-blur-md"
|
||||
className="fixed inset-0 z-[9999] flex items-start justify-center overflow-y-auto bg-[#04070dcc] px-4 py-4 backdrop-blur-md sm:items-center sm:px-6 sm:py-6"
|
||||
onClick={(event) => {
|
||||
if (event.target === backdropRef.current) {
|
||||
onClose?.()
|
||||
@@ -681,40 +863,124 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply }) {
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="w-full max-w-3xl overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||||
<div className="flex max-h-[calc(100vh-2rem)] w-full max-w-6xl flex-col overflow-hidden rounded-3xl border border-white/10 bg-[linear-gradient(180deg,rgba(16,22,34,0.98),rgba(8,12,19,0.98))] shadow-[0_30px_80px_rgba(0,0,0,0.55)] sm:max-h-[calc(100vh-3rem)]">
|
||||
<div className="border-b border-white/[0.06] bg-white/[0.02] px-6 py-5">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/35">Structured Import</p>
|
||||
<h3 className="mt-2 text-lg font-semibold text-white">Paste lesson JSON</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-white/65">Use this to seed the lesson form with structured content before you refine it in the editor.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<div className="grid gap-3">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
rows={16}
|
||||
placeholder={'{\n "title": "Prompt engineering for cleaner scene direction",\n "excerpt": "Short summary...",\n "content": "<p>Rich HTML body...</p>",\n "category": "Prompting",\n "difficulty": "beginner"\n}'}
|
||||
className="rounded-[24px] border border-white/10 bg-slate-950/80 px-4 py-4 font-mono text-sm leading-6 text-slate-100 outline-none"
|
||||
/>
|
||||
{error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="grid gap-5 px-6 py-5 xl:grid-cols-[minmax(0,1.1fr)_minmax(320px,0.9fr)]">
|
||||
<div className="grid gap-3">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
rows={16}
|
||||
placeholder={exampleValue}
|
||||
className="min-h-[320px] rounded-[24px] border border-white/10 bg-slate-950/80 px-4 py-4 font-mono text-sm leading-6 text-slate-100 outline-none"
|
||||
/>
|
||||
{error ? <div className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-4 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accepted keys</div>
|
||||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||||
<p>title, slug, excerpt</p>
|
||||
<p>lesson_number, course_order, series_name</p>
|
||||
<p>content_markdown, markdown, md</p>
|
||||
<p>content, body, html</p>
|
||||
<p>category_id, category_slug, category</p>
|
||||
<p>difficulty, access_level, lesson_type</p>
|
||||
<p>cover_image, cover, cover_url</p>
|
||||
<p>article_cover_image, article_cover, article_cover_url</p>
|
||||
<p>tags</p>
|
||||
<p>video_url</p>
|
||||
<p>reading_minutes, published_at</p>
|
||||
<p>seo_title, seo_description, featured, active</p>
|
||||
<div className="grid content-start gap-4">
|
||||
<div className="rounded-[24px] border border-white/10 bg-white/[0.03] p-2">
|
||||
<div className="flex flex-wrap gap-2" role="tablist" aria-label="Lesson import reference panels">
|
||||
{[
|
||||
{ id: 'structure', label: 'Structure', icon: 'fa-brackets-curly' },
|
||||
{ id: 'fields', label: 'Fields', icon: 'fa-table-columns' },
|
||||
{ id: 'prompt', label: 'Prompt', icon: 'fa-wand-magic-sparkles' },
|
||||
{ id: 'notes', label: 'Notes', icon: 'fa-list-check' },
|
||||
].map((tab) => {
|
||||
const isActive = tab.id === activeReferenceTab
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => setActiveReferenceTab(tab.id)}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-2xl border px-3.5 py-2 text-xs font-semibold uppercase tracking-[0.14em] transition',
|
||||
isActive
|
||||
? 'border-sky-300/25 bg-sky-300/12 text-sky-100 ring-1 ring-sky-300/20'
|
||||
: 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-sky-300/30 hover:bg-sky-300/10 hover:text-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<i className={`fa-solid ${tab.icon} text-[10px]`} />
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-[20px] border border-white/10 bg-slate-950/50 p-4 text-sm text-slate-300">
|
||||
{activeReferenceTab === 'structure' ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accepted structure</div>
|
||||
<button type="button" onClick={onCopyExample} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy example</button>
|
||||
</div>
|
||||
<pre className="mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300">{exampleValue}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeReferenceTab === 'fields' ? (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accepted keys</div>
|
||||
<div className="mt-3 grid gap-3 text-slate-400 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Core</p>
|
||||
<p className="mt-2 text-xs leading-6">title, slug, excerpt</p>
|
||||
<p className="text-xs leading-6">lesson_number, course_order, series_name</p>
|
||||
<p className="text-xs leading-6">difficulty, access_level, lesson_type</p>
|
||||
<p className="text-xs leading-6">reading_minutes, published_at</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Body</p>
|
||||
<p className="mt-2 text-xs leading-6">content_markdown, markdown, md</p>
|
||||
<p className="text-xs leading-6">content, body, html</p>
|
||||
<p className="text-xs leading-6">tags, video_url</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Taxonomy</p>
|
||||
<p className="mt-2 text-xs leading-6">category_id, category_slug, category</p>
|
||||
<p className="text-xs leading-6">featured, active</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-3 py-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Media + SEO</p>
|
||||
<p className="mt-2 text-xs leading-6">cover_image, cover, cover_url</p>
|
||||
<p className="text-xs leading-6">article_cover_image, article_cover, article_cover_url</p>
|
||||
<p className="text-xs leading-6">seo_title, seo_description</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeReferenceTab === 'prompt' ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">ChatGPT helper prompt</div>
|
||||
<button type="button" onClick={onCopyPrompt} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-white/[0.08]">Copy prompt</button>
|
||||
</div>
|
||||
<pre className="mt-3 max-h-[360px] overflow-auto rounded-2xl border border-white/10 bg-slate-950/70 p-3 text-xs leading-6 text-slate-300 whitespace-pre-wrap">{promptValue}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeReferenceTab === 'notes' ? (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">What gets applied</div>
|
||||
<div className="mt-3 space-y-2 leading-6 text-slate-400">
|
||||
<p>The JSON updates only recognized lesson fields already supported by the editor.</p>
|
||||
<p>Markdown import updates both the Markdown source and rendered HTML body.</p>
|
||||
<p>Category values can match by id, slug, or visible category name.</p>
|
||||
<p>Imported values become editable immediately before you save the lesson.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -859,10 +1125,19 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
const [courseSaveProcessing, setCourseSaveProcessing] = useState({})
|
||||
const revisions = useMemo(() => Array.isArray(editorContext.revisions) ? editorContext.revisions : [], [editorContext.revisions])
|
||||
const [revisionFieldSelections, setRevisionFieldSelections] = useState({})
|
||||
const [toast, setToast] = useState({ id: 0, visible: false, message: '', variant: 'success' })
|
||||
const csrfToken = useMemo(() => {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}, [])
|
||||
const showToast = (message, variant = 'error') => {
|
||||
setToast({
|
||||
id: Date.now() + Math.random(),
|
||||
visible: true,
|
||||
message,
|
||||
variant,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMarkdownContentChange = (nextMarkdown) => {
|
||||
const nextHtml = convertLessonMarkdownToHtml(nextMarkdown)
|
||||
@@ -878,6 +1153,12 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
startTransition(() => {
|
||||
form.setData('content', nextHtml)
|
||||
if (form.data.content_source === 'markdown') {
|
||||
if (!lessonMarkdownTurndown) {
|
||||
form.setData('content_source', 'html')
|
||||
form.setData('content_markdown', '')
|
||||
return
|
||||
}
|
||||
|
||||
form.setData('content_markdown', convertLessonHtmlToMarkdown(nextHtml))
|
||||
return
|
||||
}
|
||||
@@ -934,6 +1215,64 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
const next = categories.map((category) => ({ value: String(category.id), label: category.name }))
|
||||
return [{ value: '', label: 'No category' }, ...next]
|
||||
}, [categories])
|
||||
const selectedCategoryName = useMemo(() => {
|
||||
const selectedId = String(form.data.category_id || '').trim()
|
||||
if (!selectedId) return ''
|
||||
|
||||
const match = categories.find((category) => String(category.id) === selectedId)
|
||||
return match ? String(match.name || '') : ''
|
||||
}, [categories, form.data.category_id])
|
||||
const jsonImportExampleValue = useMemo(() => buildLessonImportExample({
|
||||
title: form.data.title,
|
||||
excerpt: form.data.excerpt,
|
||||
difficulty: form.data.difficulty,
|
||||
accessLevel: form.data.access_level,
|
||||
lessonType: form.data.lesson_type,
|
||||
categoryName: selectedCategoryName,
|
||||
}), [form.data.access_level, form.data.difficulty, form.data.excerpt, form.data.lesson_type, form.data.title, selectedCategoryName])
|
||||
const jsonImportPromptValue = useMemo(() => buildLessonImportPrompt({
|
||||
title: form.data.title,
|
||||
difficulty: form.data.difficulty,
|
||||
accessLevel: form.data.access_level,
|
||||
lessonType: form.data.lesson_type,
|
||||
categoryName: selectedCategoryName,
|
||||
}), [form.data.access_level, form.data.difficulty, form.data.lesson_type, form.data.title, selectedCategoryName])
|
||||
const selectedCourseName = useMemo(() => selectedCourses[0]?.label || 'Unassigned', [selectedCourses])
|
||||
const lessonNumberValue = useMemo(() => {
|
||||
const numeric = Number(form.data.lesson_number)
|
||||
if (Number.isFinite(numeric) && numeric > 0) return String(numeric)
|
||||
|
||||
const suggested = Number(numberingContext?.lesson_number?.suggested || 0)
|
||||
if (Number.isFinite(suggested) && suggested > 0) return String(suggested)
|
||||
|
||||
return '1'
|
||||
}, [form.data.lesson_number, numberingContext])
|
||||
const lessonHeroPromptValue = useMemo(() => buildLessonHeroPrompt({
|
||||
title: form.data.title,
|
||||
excerpt: form.data.excerpt,
|
||||
categoryName: selectedCategoryName,
|
||||
tags: String(form.data.tags || '').split(',').map((tag) => tag.trim()).filter(Boolean),
|
||||
}), [form.data.excerpt, form.data.tags, form.data.title, selectedCategoryName])
|
||||
const lessonArticleCoverPromptValue = useMemo(() => buildLessonArticleCoverPrompt({
|
||||
courseName: selectedCourseName,
|
||||
lessonNumber: lessonNumberValue,
|
||||
title: form.data.title,
|
||||
excerpt: form.data.excerpt,
|
||||
categoryName: selectedCategoryName,
|
||||
tags: String(form.data.tags || '').split(',').map((tag) => tag.trim()).filter(Boolean),
|
||||
aspectRatio: '3:2',
|
||||
mainVisualSubject: `A premium editorial visual focused on ${String(form.data.title || 'this lesson')}`,
|
||||
previewImageDescription: `A cinematic article-cover scene that clearly supports ${String(form.data.title || 'the lesson topic')} and feels premium at thumbnail size.`,
|
||||
}), [form.data.excerpt, form.data.tags, form.data.title, lessonNumberValue, selectedCategoryName, selectedCourseName])
|
||||
const lessonHeaderNumberLabel = useMemo(() => {
|
||||
const numeric = Number(form.data.lesson_number)
|
||||
|
||||
if (!Number.isFinite(numeric) || numeric < 1) {
|
||||
return 'Unnumbered'
|
||||
}
|
||||
|
||||
return `Lesson ${String(numeric).padStart(2, '0')}`
|
||||
}, [form.data.lesson_number])
|
||||
|
||||
useEffect(() => {
|
||||
if (method !== 'post' || lessonNumberAutofillRef.current) return
|
||||
@@ -1027,12 +1366,26 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
const payload = buildLessonPayload(form.data)
|
||||
form.transform(() => payload)
|
||||
|
||||
const submitOptions = {
|
||||
preserveScroll: true,
|
||||
onError: (errors) => {
|
||||
const nextTab = firstLessonErrorTab(errors)
|
||||
|
||||
if (nextTab) {
|
||||
setActiveTab(nextTab)
|
||||
}
|
||||
|
||||
showToast(firstErrorMessage(errors), 'error')
|
||||
},
|
||||
onFinish: () => form.transform((data) => data),
|
||||
}
|
||||
|
||||
if (method === 'patch') {
|
||||
form.patch(submitUrl)
|
||||
form.patch(submitUrl, submitOptions)
|
||||
return
|
||||
}
|
||||
|
||||
form.post(submitUrl)
|
||||
form.post(submitUrl, submitOptions)
|
||||
}
|
||||
|
||||
const deleteLesson = () => {
|
||||
@@ -1137,6 +1490,20 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
setMarkdownImportOpen(false)
|
||||
}
|
||||
|
||||
const copyImportHelperText = async (text, successMessage) => {
|
||||
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
|
||||
showToast('Clipboard copy is not available in this browser.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(text || ''))
|
||||
showToast(successMessage, 'success')
|
||||
} catch {
|
||||
showToast('Could not copy import helper text.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const createCategory = async () => {
|
||||
setCategorySaving(true)
|
||||
setCategoryError('')
|
||||
@@ -1264,6 +1631,7 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
<Link href={indexUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white transition hover:bg-white/[0.08]">Back to lessons</Link>
|
||||
<span>{destroyUrl ? 'Edit lesson' : 'New lesson'}</span>
|
||||
<span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-3 py-1.5 text-sky-100">{lessonHeaderNumberLabel}</span>
|
||||
</div>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{form.data.title || 'Untitled academy lesson'}</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-7 text-slate-300">Use the same richer writing flow as the newsroom: drag in the cover, shape the article with the rich editor, and keep publishing details in the same place.</p>
|
||||
@@ -1851,6 +2219,86 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
<TextAreaField label="SEO description" value={form.data.seo_description} onChange={(event) => form.setData('seo_description', event.target.value)} error={form.errors.seo_description} rows={4} hint="Keep this tighter than the excerpt and focused on search intent." />
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="lesson-cover" eyebrow="Cover image" title="Hero asset" description="Use drag and drop for the lesson image, or paste a direct URL when you already have one." className={sectionClassName('lesson-cover')}>
|
||||
<div className="grid gap-5 lg:grid-cols-2 lg:items-start">
|
||||
<div className="grid gap-4">
|
||||
<WorldMediaUploadField
|
||||
label="Hero cover"
|
||||
slot="cover"
|
||||
value={form.data.cover_image}
|
||||
previewUrl={coverPreviewUrl}
|
||||
emptyLabel="Drop a hero cover"
|
||||
helperText="Upload a wide landscape image for academy cards, previews, and social sharing. Keep it cinematic, readable at small sizes, and free of embedded text."
|
||||
uploadUrl={editorContext.coverUploadUrl}
|
||||
deleteUrl={editorContext.coverDeleteUrl}
|
||||
onChange={({ path, url }) => {
|
||||
setStagedCoverPath(path || '')
|
||||
form.setData('cover_image', path || '')
|
||||
setCoverPreviewUrl(url || '')
|
||||
}}
|
||||
isTemporaryValue={Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath}
|
||||
/>
|
||||
<FieldError message={form.errors.cover_image} />
|
||||
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced hero cover path or URL</span>
|
||||
<input value={form.data.cover_image} onChange={(event) => handleManualCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs leading-5 text-slate-500">Use this for migrations, imported lessons, or when you already know the exact asset path to use.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<CopyablePromptCard
|
||||
eyebrow="ChatGPT prompt"
|
||||
title="Copy this for the hero cover"
|
||||
description="Paste this into ChatGPT when you want a new hero image for the lesson."
|
||||
prompt={lessonHeroPromptValue}
|
||||
onCopy={() => {
|
||||
void copyImportHelperText(lessonHeroPromptValue, 'Hero cover prompt copied.')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="lesson-article-cover" eyebrow="Article cover" title="Inline article image" description="This image is rendered just before the lesson content begins." className={sectionClassName('lesson-article-cover')}>
|
||||
<div className="grid gap-5 lg:grid-cols-2 lg:items-start">
|
||||
<div className="grid gap-4">
|
||||
<WorldMediaUploadField
|
||||
label="Inline article cover"
|
||||
slot="cover"
|
||||
value={form.data.article_cover_image}
|
||||
previewUrl={articleCoverPreviewUrl}
|
||||
emptyLabel="Drop an inline article cover"
|
||||
helperText="Upload the image that appears above the lesson body. Use a strong landscape image that still reads well inside the article column."
|
||||
uploadUrl={editorContext.coverUploadUrl}
|
||||
deleteUrl={editorContext.coverDeleteUrl}
|
||||
onChange={({ path, url }) => {
|
||||
setStagedArticleCoverPath(path || '')
|
||||
form.setData('article_cover_image', path || '')
|
||||
setArticleCoverPreviewUrl(url || '')
|
||||
}}
|
||||
isTemporaryValue={Boolean(stagedArticleCoverPath) && form.data.article_cover_image === stagedArticleCoverPath}
|
||||
/>
|
||||
<FieldError message={form.errors.article_cover_image} />
|
||||
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced inline article cover path or URL</span>
|
||||
<input value={form.data.article_cover_image} onChange={(event) => handleManualArticleCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs leading-5 text-slate-500">Use this when the article image already exists in storage or needs to point to an external source.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<CopyablePromptCard
|
||||
eyebrow="ChatGPT prompt"
|
||||
title="Copy this for the inline article image"
|
||||
description="Paste this into ChatGPT when you want a cleaner image that sits above the lesson body."
|
||||
prompt={lessonArticleCoverPromptValue}
|
||||
onCopy={() => {
|
||||
void copyImportHelperText(lessonArticleCoverPromptValue, 'Article cover prompt copied.')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="lesson-categories" eyebrow="Lesson categories" title="Create category inline" description="Add lesson categories without leaving the writing flow." className={sectionClassName('lesson-categories')} actions={<a href={editorContext.categoryManageUrl} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Manage all categories</a>}>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<div className="grid gap-3">
|
||||
@@ -1886,62 +2334,6 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="lesson-cover" eyebrow="Cover image" title="Hero asset" description="Use drag and drop for the lesson image, or paste a direct URL when you already have one." className={sectionClassName('lesson-cover')}>
|
||||
<div className="grid gap-4">
|
||||
<WorldMediaUploadField
|
||||
label="Lesson cover"
|
||||
slot="cover"
|
||||
value={form.data.cover_image}
|
||||
previewUrl={coverPreviewUrl}
|
||||
emptyLabel="Drop a lesson cover"
|
||||
helperText="Upload the hero image directly to object storage. A wide landscape image works best for academy cards, previews, and social sharing."
|
||||
uploadUrl={editorContext.coverUploadUrl}
|
||||
deleteUrl={editorContext.coverDeleteUrl}
|
||||
onChange={({ path, url }) => {
|
||||
setStagedCoverPath(path || '')
|
||||
form.setData('cover_image', path || '')
|
||||
setCoverPreviewUrl(url || '')
|
||||
}}
|
||||
isTemporaryValue={Boolean(stagedCoverPath) && form.data.cover_image === stagedCoverPath}
|
||||
/>
|
||||
<FieldError message={form.errors.cover_image} />
|
||||
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced cover path or URL</span>
|
||||
<input value={form.data.cover_image} onChange={(event) => handleManualCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs leading-5 text-slate-500">Keep this for migrations, imported lessons, or when you already know the exact asset path to use.</span>
|
||||
</label>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="lesson-article-cover" eyebrow="Article cover" title="Inline article image" description="This image is rendered just before the lesson content begins." className={sectionClassName('lesson-article-cover')}>
|
||||
<div className="grid gap-4">
|
||||
<WorldMediaUploadField
|
||||
label="Article cover"
|
||||
slot="cover"
|
||||
value={form.data.article_cover_image}
|
||||
previewUrl={articleCoverPreviewUrl}
|
||||
emptyLabel="Drop an article cover"
|
||||
helperText="Upload the image that appears above the lesson body. Use a strong wide image that still reads well inside the article column."
|
||||
uploadUrl={editorContext.coverUploadUrl}
|
||||
deleteUrl={editorContext.coverDeleteUrl}
|
||||
onChange={({ path, url }) => {
|
||||
setStagedArticleCoverPath(path || '')
|
||||
form.setData('article_cover_image', path || '')
|
||||
setArticleCoverPreviewUrl(url || '')
|
||||
}}
|
||||
isTemporaryValue={Boolean(stagedArticleCoverPath) && form.data.article_cover_image === stagedArticleCoverPath}
|
||||
/>
|
||||
<FieldError message={form.errors.article_cover_image} />
|
||||
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Advanced article cover path or URL</span>
|
||||
<input value={form.data.article_cover_image} onChange={(event) => handleManualArticleCoverChange(event.target.value)} placeholder="Optional external URL or stored object path" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
<span className="text-xs leading-5 text-slate-500">Use this when the article image already exists in storage or needs to point to an external source.</span>
|
||||
</label>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard id="lesson-revisions" eyebrow="Safety net" title="Revision history" description="Each lesson update now saves the previous state first. Restore the full lesson or a single field when something goes wrong." className={sectionClassName('lesson-revisions')}>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[24px] border border-sky-300/18 bg-sky-300/8 p-4 text-sm leading-6 text-slate-300">
|
||||
@@ -2085,12 +2477,20 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
open={jsonImportOpen}
|
||||
value={jsonImportValue}
|
||||
error={jsonImportError}
|
||||
exampleValue={jsonImportExampleValue}
|
||||
promptValue={jsonImportPromptValue}
|
||||
onChange={(nextValue) => {
|
||||
setJsonImportValue(nextValue)
|
||||
if (jsonImportError) {
|
||||
setJsonImportError('')
|
||||
}
|
||||
}}
|
||||
onCopyExample={() => {
|
||||
void copyImportHelperText(jsonImportExampleValue, 'Lesson JSON example copied.')
|
||||
}}
|
||||
onCopyPrompt={() => {
|
||||
void copyImportHelperText(jsonImportPromptValue, 'Lesson import prompt copied.')
|
||||
}}
|
||||
onClose={() => {
|
||||
setJsonImportOpen(false)
|
||||
setJsonImportError('')
|
||||
@@ -2114,6 +2514,15 @@ export default function LessonEditor({ title, subtitle, fields, record, submitUr
|
||||
}}
|
||||
onApply={applyMarkdownImport}
|
||||
/>
|
||||
|
||||
<ShareToast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
visible={toast.visible}
|
||||
variant={toast.variant}
|
||||
duration={toast.variant === 'error' ? 3200 : 2200}
|
||||
onHide={() => setToast((current) => ({ ...current, visible: false }))}
|
||||
/>
|
||||
</AdminLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user