Implement academy analytics, billing, and web stories updates
This commit is contained in:
@@ -24,6 +24,7 @@ const buildAdminNavGroups = (isAdmin) => [
|
||||
{ label: 'Stories', href: '/moderation/stories', icon: 'fa-solid fa-feather-pointed' },
|
||||
{ label: 'Artworks', href: '/moderation/artworks', icon: 'fa-solid fa-images' },
|
||||
{ label: 'Featured Artworks', href: '/moderation/artworks/featured', icon: 'fa-solid fa-star' },
|
||||
{ label: 'Web Stories', href: '/moderation/web-stories', icon: 'fa-solid fa-book-open-reader' },
|
||||
{ label: 'Homepage Announcements', href: '/moderation/homepage/announcements', icon: 'fa-solid fa-bullhorn' },
|
||||
{ label: 'Upload Queue', href: '/moderation/uploads', icon: 'fa-solid fa-cloud-arrow-up' },
|
||||
{ label: 'Username Queue', href: '/moderation/usernames/moderation', icon: 'fa-solid fa-id-badge' },
|
||||
@@ -34,6 +35,8 @@ const buildAdminNavGroups = (isAdmin) => [
|
||||
label: 'Academy',
|
||||
items: [
|
||||
{ label: 'Academy Dashboard', href: '/moderation/academy/dashboard', icon: 'fa-solid fa-graduation-cap' },
|
||||
{ label: 'Academy Billing', href: '/moderation/academy/billing', icon: 'fa-solid fa-credit-card' },
|
||||
{ label: 'Academy Analytics', href: '/moderation/academy/analytics', icon: 'fa-solid fa-chart-line' },
|
||||
{ label: 'Academy Courses', href: '/moderation/academy/courses', icon: 'fa-solid fa-road' },
|
||||
{ label: 'Academy Lessons', href: '/moderation/academy/lessons', icon: 'fa-solid fa-book-open' },
|
||||
{ label: 'Academy Prompts', href: '/moderation/academy/prompts', icon: 'fa-solid fa-wand-magic-sparkles' },
|
||||
|
||||
152
resources/js/Pages/Academy/Billing/Account.jsx
Normal file
152
resources/js/Pages/Academy/Billing/Account.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
import AccessBadge from '../../../components/academy/billing/AccessBadge'
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return null
|
||||
try {
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) {
|
||||
const endsAt = formatDate(subscription?.endsAt)
|
||||
const onGracePeriod = subscription?.onGracePeriod === true
|
||||
const subscriptionActive = subscription?.active === true
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.14),_transparent_26%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title="Academy Subscription" />
|
||||
|
||||
<div className="mx-auto max-w-[1280px] space-y-8">
|
||||
{/* Header */}
|
||||
<section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(15,23,42,0.96))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-100/85">Skinbase Academy</p>
|
||||
<AccessBadge tier={currentTier} />
|
||||
</div>
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">
|
||||
{isSubscribed ? 'Your subscription' : 'Academy subscription'}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-300">
|
||||
{isSubscribed
|
||||
? 'Your Academy access is active. Manage, upgrade, or cancel your subscription here at any time.'
|
||||
: 'You are on the free Academy tier. Upgrade to Creator or Pro to unlock premium content.'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Grace period warning */}
|
||||
{onGracePeriod && endsAt ? (
|
||||
<section className="rounded-[30px] border border-amber-300/25 bg-amber-300/[0.06] px-6 py-5">
|
||||
<p className="font-semibold text-amber-100">Your subscription was cancelled and will end on {endsAt}.</p>
|
||||
<p className="mt-2 text-sm leading-6 text-amber-100/75">You still have full access until that date. Open the subscription portal to resume your plan if you change your mind.</p>
|
||||
<Link
|
||||
href={links.portal}
|
||||
className="mt-4 inline-flex items-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-2.5 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/20"
|
||||
>
|
||||
Resume subscription
|
||||
</Link>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* No subscription: upgrade CTA */}
|
||||
{!isSubscribed ? (
|
||||
<section className="rounded-[30px] border border-white/10 bg-[linear-gradient(135deg,rgba(8,47,73,0.92),rgba(30,41,59,0.94))] p-6 md:p-7">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100/85">Upgrade</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white">Choose a plan to get started</h2>
|
||||
<p className="mt-3 max-w-xl text-sm leading-7 text-slate-200/90">
|
||||
Creator unlocks premium lessons and the full prompt library for €4.99/month. Pro gives you everything — all lessons, the advanced content track, and every new Academy drop — for €9.99/month.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={links.pricing || '/academy/pricing'}
|
||||
className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18"
|
||||
>
|
||||
See plans and pricing
|
||||
</Link>
|
||||
<Link
|
||||
href={links.academy || '/academy'}
|
||||
className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
|
||||
>
|
||||
Back to Academy
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Active subscription: details + manager */}
|
||||
{isSubscribed ? (
|
||||
<section className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div className="space-y-5 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 md:p-7">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Subscription details</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Active plan</p>
|
||||
<p className="mt-2 text-lg font-semibold text-white">{activePlan?.label || 'Academy plan'}</p>
|
||||
{activePlan?.price_display ? (
|
||||
<p className="mt-1 text-sm text-slate-400">{activePlan.price_display} / month</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Status</p>
|
||||
<p className="mt-2 text-lg font-semibold capitalize text-white">
|
||||
{onGracePeriod ? 'Cancelling' : subscriptionActive ? 'Active' : (subscription?.status || 'Active')}
|
||||
</p>
|
||||
{onGracePeriod && endsAt ? (
|
||||
<p className="mt-1 text-sm text-amber-300/80">Access ends {endsAt}</p>
|
||||
) : null}
|
||||
{!onGracePeriod && subscriptionActive ? (
|
||||
<p className="mt-1 text-sm text-emerald-300/80">Renews automatically</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.18em] text-slate-500">Your Academy access</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3">
|
||||
<AccessBadge tier={currentTier} />
|
||||
<p className="text-sm text-slate-300">
|
||||
{currentTier === 'pro'
|
||||
? 'Full access to all Academy lessons and content.'
|
||||
: currentTier === 'creator'
|
||||
? 'Full access to all Creator lessons and prompts.'
|
||||
: 'Access to free Academy content.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="space-y-3 rounded-[32px] border border-white/10 bg-black/20 p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-300">Manage</p>
|
||||
<p className="text-xs leading-6 text-slate-400">
|
||||
Use the subscription portal to upgrade, downgrade, or cancel. Changes take effect at your next billing date.
|
||||
</p>
|
||||
<Link
|
||||
href={links.portal}
|
||||
className="mt-2 inline-flex w-full items-center justify-center rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18"
|
||||
>
|
||||
Upgrade, downgrade or cancel
|
||||
</Link>
|
||||
<Link
|
||||
href={links.pricing || '/academy/pricing'}
|
||||
className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
|
||||
>
|
||||
Compare plans
|
||||
</Link>
|
||||
<Link
|
||||
href={links.academy || '/academy'}
|
||||
className="inline-flex w-full items-center justify-center rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
|
||||
>
|
||||
Go to Academy
|
||||
</Link>
|
||||
</aside>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
23
resources/js/Pages/Academy/Billing/Cancel.jsx
Normal file
23
resources/js/Pages/Academy/Billing/Cancel.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
|
||||
export default function AcademyBillingCancel({ message, links = {} }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(148,163,184,0.14),_transparent_26%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title="Academy Billing Canceled" />
|
||||
|
||||
<div className="mx-auto max-w-[920px] space-y-8">
|
||||
<section className="rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(67,20,7,0.78))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-100/85">Checkout canceled</p>
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">No payment was made.</h1>
|
||||
<p className="mt-4 max-w-2xl text-base leading-8 text-slate-300">{message}</p>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={links.pricing || '/academy/pricing'} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Return to pricing</Link>
|
||||
<Link href={links.academy || '/academy'} className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]">Back to Academy</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
220
resources/js/Pages/Academy/Billing/Pricing.jsx
Normal file
220
resources/js/Pages/Academy/Billing/Pricing.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React from 'react'
|
||||
import { usePage, Link } from '@inertiajs/react'
|
||||
import SeoHead from '../../../components/seo/SeoHead'
|
||||
import AccessBadge from '../../../components/academy/billing/AccessBadge'
|
||||
import PlanCard from '../../../components/academy/billing/PlanCard'
|
||||
import { trackUpgradeClick, useAcademyPageAnalytics } from '../../../lib/academyAnalytics'
|
||||
|
||||
function getCsrfToken() {
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
function heroText(currentTier, isSubscribed) {
|
||||
if (isSubscribed && currentTier === 'pro') {
|
||||
return {
|
||||
heading: 'You have full Academy access.',
|
||||
body: 'All lessons, prompts, and Academy content are unlocked on your Pro plan. To upgrade, downgrade, or cancel, use the subscription manager below.',
|
||||
}
|
||||
}
|
||||
if (isSubscribed && currentTier === 'creator') {
|
||||
return {
|
||||
heading: "You're on the Creator plan.",
|
||||
body: 'Creator content is fully unlocked. Upgrade to Pro anytime to access the advanced lesson track and everything new that launches at the Pro tier.',
|
||||
}
|
||||
}
|
||||
if (currentTier === 'admin') {
|
||||
return {
|
||||
heading: 'Academy plans.',
|
||||
body: 'Your admin account already has full Academy access. Browse the plans below.',
|
||||
}
|
||||
}
|
||||
if (isSubscribed) {
|
||||
return {
|
||||
heading: 'Manage your Academy subscription.',
|
||||
body: 'Your plan is active. Review your options below or use the subscription manager to make changes.',
|
||||
}
|
||||
}
|
||||
return {
|
||||
heading: 'Unlock everything in Academy.',
|
||||
body: "Start free and upgrade when you're ready. Creator unlocks premium lessons and the full prompt library. Pro adds the advanced lesson track and is the highest Academy tier.",
|
||||
}
|
||||
}
|
||||
|
||||
function SidePanel({ currentTier, isSubscribed, activePlanLabel, activePlanPrice, manageHref }) {
|
||||
if (isSubscribed) {
|
||||
return (
|
||||
<div className="rounded-[30px] border border-emerald-300/20 bg-emerald-300/[0.06] p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-emerald-200/80">Your subscription</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] uppercase tracking-[0.16em] text-slate-500">Active plan</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{activePlanLabel || 'Academy plan'}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<p className="text-[10px] uppercase tracking-[0.16em] text-slate-500">Billed monthly</p>
|
||||
<p className="mt-1 text-sm font-semibold text-white">{activePlanPrice || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{manageHref ? (
|
||||
<Link href={manageHref} className="mt-5 inline-flex w-full items-center justify-center rounded-full border border-emerald-300/30 bg-emerald-300/10 px-5 py-3 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-300/18">
|
||||
Manage subscription
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-[30px] border border-white/10 bg-black/20 p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Why upgrade?</p>
|
||||
<div className="mt-4 space-y-3">
|
||||
{[
|
||||
{ title: 'Instant access', body: 'Subscription activates the moment payment is confirmed.' },
|
||||
{ title: 'Cancel anytime', body: 'No lock-in. Keep access until the end of the billing period.' },
|
||||
{ title: 'Switch freely', body: 'Move between Creator and Pro from your subscription manager.' },
|
||||
].map(({ title, body }) => (
|
||||
<div key={title} className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||||
<p className="text-sm font-semibold text-white">{title}</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-400">{body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics }) {
|
||||
const { auth, errors, flash } = usePage().props
|
||||
|
||||
useAcademyPageAnalytics(analytics)
|
||||
|
||||
const loginHref = auth?.user ? null : `${links.login || '/login'}?intended=${encodeURIComponent(links.pricing || '/academy/pricing')}`
|
||||
|
||||
const products = catalog.map((product) => ({
|
||||
...product,
|
||||
selectedPlan: product.plans[0] || null,
|
||||
}))
|
||||
|
||||
const activePlanPrice = products
|
||||
.flatMap((p) => p.plans)
|
||||
.find((p) => p?.key === activePlanKey)?.price_display || null
|
||||
|
||||
const handleCheckout = (plan) => {
|
||||
if (!plan?.key || !links.checkout) return
|
||||
|
||||
trackUpgradeClick(analytics, {
|
||||
source: 'academy_billing_pricing',
|
||||
academy_plan: plan.key,
|
||||
academy_interval: plan.interval,
|
||||
})
|
||||
|
||||
const form = document.createElement('form')
|
||||
form.method = 'POST'
|
||||
form.action = links.checkout
|
||||
form.style.display = 'none'
|
||||
|
||||
const csrfInput = document.createElement('input')
|
||||
csrfInput.type = 'hidden'
|
||||
csrfInput.name = '_token'
|
||||
csrfInput.value = getCsrfToken()
|
||||
|
||||
const planInput = document.createElement('input')
|
||||
planInput.type = 'hidden'
|
||||
planInput.name = 'plan'
|
||||
planInput.value = plan.key
|
||||
|
||||
form.appendChild(csrfInput)
|
||||
form.appendChild(planInput)
|
||||
document.body.appendChild(form)
|
||||
form.submit()
|
||||
}
|
||||
|
||||
const hero = heroText(currentTier, isSubscribed)
|
||||
const showFreeBadgeAsCurrentPlan = currentTier === 'free' && !isSubscribed && auth?.user
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(251,191,36,0.16),_transparent_22%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.18),_transparent_26%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title="Skinbase Academy — Plans & Pricing" description={seo?.description} />
|
||||
|
||||
<div className="mx-auto max-w-[1380px] space-y-8">
|
||||
{/* Hero */}
|
||||
<section className="overflow-hidden rounded-[40px] border border-white/10 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.9),rgba(67,20,7,0.82))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10 lg:p-12">
|
||||
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_320px] xl:items-start">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-amber-200/85">Skinbase Academy</p>
|
||||
{currentTier !== 'free' ? <AccessBadge tier={currentTier} /> : null}
|
||||
</div>
|
||||
<h1 className="mt-4 max-w-3xl text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl xl:text-6xl">{hero.heading}</h1>
|
||||
<p className="mt-5 max-w-2xl text-base leading-8 text-slate-300 md:text-lg">{hero.body}</p>
|
||||
|
||||
{errors?.plan ? <p className="mt-4 text-sm font-medium text-rose-200">{errors.plan}</p> : null}
|
||||
{flash?.error ? <p className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm font-medium text-rose-100">{flash.error}</p> : null}
|
||||
{flash?.success ? <p className="mt-4 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm font-medium text-emerald-100">{flash.success}</p> : null}
|
||||
</div>
|
||||
|
||||
<SidePanel
|
||||
currentTier={currentTier}
|
||||
isSubscribed={isSubscribed}
|
||||
activePlanLabel={activePlanLabel}
|
||||
activePlanPrice={activePlanPrice}
|
||||
manageHref={links.billingAccount}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Plan grid */}
|
||||
<section className="grid gap-5 xl:grid-cols-[minmax(0,0.9fr)_1fr_1fr]">
|
||||
{/* Free / Explorer card */}
|
||||
<article className={`rounded-[32px] border p-6 md:p-7 ${showFreeBadgeAsCurrentPlan ? 'border-white/20 bg-white/[0.06]' : 'border-white/10 bg-white/[0.04]'}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">Free</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">Explorer</h2>
|
||||
</div>
|
||||
{showFreeBadgeAsCurrentPlan
|
||||
? <span className="rounded-full border border-white/20 bg-white/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-200">Your plan</span>
|
||||
: <AccessBadge tier="free" />}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-3xl font-semibold tracking-[-0.04em] text-white">Free</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">No payment needed</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">Everything you need to explore Academy, follow public lessons, and see a preview of what the paid tiers include.</p>
|
||||
|
||||
<div className="mt-5 space-y-3 text-sm text-slate-300">
|
||||
{[
|
||||
'Public lessons and Academy listings',
|
||||
'Prompt previews and public documentation',
|
||||
'Community access and updates',
|
||||
'Upgrade to Creator or Pro anytime',
|
||||
].map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-2.5 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<span className="mt-px shrink-0 text-slate-500">✓</span>
|
||||
<span>{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{products.map((product) => (
|
||||
<PlanCard
|
||||
key={product.tier}
|
||||
product={product}
|
||||
selectedPlan={product.selectedPlan}
|
||||
currentTier={currentTier}
|
||||
isSubscribed={isSubscribed}
|
||||
activePlanKey={activePlanKey}
|
||||
billingEnabled={billingEnabled}
|
||||
loginHref={loginHref}
|
||||
manageHref={links.billingAccount}
|
||||
onCheckout={handleCheckout}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
45
resources/js/Pages/Academy/Billing/Success.jsx
Normal file
45
resources/js/Pages/Academy/Billing/Success.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import { Head, Link } from '@inertiajs/react'
|
||||
import AccessBadge from '../../../components/academy/billing/AccessBadge'
|
||||
|
||||
export default function AcademyBillingSuccess({ currentTier, isSubscribed, links = {} }) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.14),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(16,185,129,0.14),_transparent_24%),linear-gradient(180deg,_#07111f_0%,_#0f172a_45%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<Head title="Subscription Confirmed" />
|
||||
|
||||
<div className="mx-auto w-full max-w-[640px] space-y-6">
|
||||
<section className="rounded-[40px] border border-emerald-300/20 bg-[linear-gradient(135deg,rgba(7,17,31,0.95),rgba(12,24,45,0.92),rgba(6,78,59,0.82))] p-8 shadow-[0_32px_100px_rgba(2,6,23,0.42)] md:p-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl leading-none">🎉</span>
|
||||
{isSubscribed ? <AccessBadge tier={currentTier} /> : null}
|
||||
</div>
|
||||
<h1 className="mt-5 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">
|
||||
{isSubscribed ? 'Welcome to Academy.' : "You're all set."}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-lg text-base leading-8 text-slate-300">
|
||||
{isSubscribed
|
||||
? 'Your subscription is active and all premium content for your plan is now unlocked. Head to Academy and start exploring.'
|
||||
: "Your payment was confirmed and your subscription is activating now. This usually takes just a moment. If you don't see your access right away, refresh the Academy page in a few seconds."}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={links.academy || '/academy'}
|
||||
className="rounded-full border border-emerald-300/25 bg-emerald-300/12 px-5 py-3 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-300/18"
|
||||
>
|
||||
Go to Academy
|
||||
</Link>
|
||||
{links.account ? (
|
||||
<Link
|
||||
href={links.account}
|
||||
className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]"
|
||||
>
|
||||
View my subscription
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,33 @@ import React from 'react'
|
||||
import { Link, router, usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import NovaSelect from '../../components/ui/NovaSelect'
|
||||
import { trackAcademySearchResultClick, trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics'
|
||||
|
||||
function CourseCard({ course, variant = 'default' }) {
|
||||
function CourseCard({ course, variant = 'default', analytics = null, searchContext = null, position = null }) {
|
||||
const isFeatured = variant === 'featured'
|
||||
const progress = course?.progress || null
|
||||
const cover = course?.cover_image_url || course?.teaser_image_url || course?.cover_image || course?.teaser_image || ''
|
||||
const trackSearchClick = () => {
|
||||
if (!searchContext?.query) {
|
||||
return
|
||||
}
|
||||
|
||||
trackAcademySearchResultClick(analytics, searchContext, {
|
||||
contentType: 'academy_course',
|
||||
contentId: course?.id,
|
||||
position,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={course.public_url}
|
||||
onClick={trackSearchClick}
|
||||
data-academy-content-type={searchContext?.query ? 'academy_course' : undefined}
|
||||
data-academy-content-id={searchContext?.query ? course?.id : undefined}
|
||||
data-academy-search-query={searchContext?.query || undefined}
|
||||
data-academy-search-results-count={searchContext?.resultsCount || undefined}
|
||||
data-academy-search-position={position || undefined}
|
||||
className={[
|
||||
'group overflow-hidden rounded-[30px] border border-white/10 transition hover:border-sky-300/25 hover:bg-white/[0.06]',
|
||||
isFeatured ? 'bg-[linear-gradient(135deg,rgba(14,165,233,0.14),rgba(15,23,42,0.92))]' : 'bg-white/[0.04]',
|
||||
@@ -50,8 +68,15 @@ function CourseCard({ course, variant = 'default' }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl }) {
|
||||
export default function AcademyCoursesIndex({ seo, title, description, items, featuredCourses = [], filters = {}, pricingUrl, analytics }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
useAcademyPageAnalytics(analytics)
|
||||
const searchContext = analytics?.search ? {
|
||||
query: analytics.search.query,
|
||||
normalizedQuery: analytics.search.normalizedQuery,
|
||||
resultsCount: analytics.search.resultsCount,
|
||||
filters,
|
||||
} : null
|
||||
const difficultyOptions = [
|
||||
{ value: '', label: 'All levels' },
|
||||
{ value: 'beginner', label: 'Beginner' },
|
||||
@@ -77,7 +102,7 @@ export default function AcademyCoursesIndex({ seo, title, description, items, fe
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.055em] text-white md:text-5xl lg:text-6xl">{title}</h1>
|
||||
<p className="mt-5 text-base leading-8 text-slate-300 md:text-lg">{description}</p>
|
||||
</div>
|
||||
<Link href={pricingUrl} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">See Academy plans</Link>
|
||||
<Link href={pricingUrl} onClick={() => trackUpgradeClick(analytics, { source: 'academy_courses_index_hero' })} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">See Academy plans</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -86,9 +111,9 @@ export default function AcademyCoursesIndex({ seo, title, description, items, fe
|
||||
|
||||
{featuredCourses.length ? (
|
||||
<section className="grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
|
||||
<CourseCard course={featuredCourses[0]} variant="featured" />
|
||||
<CourseCard course={featuredCourses[0]} variant="featured" analytics={analytics} searchContext={searchContext} position={1} />
|
||||
<div className="grid gap-5">
|
||||
{featuredCourses.slice(1, 3).map((course) => <CourseCard key={course.id} course={course} />)}
|
||||
{featuredCourses.slice(1, 3).map((course, index) => <CourseCard key={course.id} course={course} analytics={analytics} searchContext={searchContext} position={index + 2} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
@@ -116,7 +141,7 @@ export default function AcademyCoursesIndex({ seo, title, description, items, fe
|
||||
<section className="rounded-[30px] border border-white/10 bg-white/[0.04] px-6 py-12 text-center text-slate-400">No published Academy courses matched these filters.</section>
|
||||
) : (
|
||||
<section className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||
{items.data.map((course) => <CourseCard key={course.id} course={course} />)}
|
||||
{items.data.map((course, index) => <CourseCard key={course.id} course={course} analytics={analytics} searchContext={searchContext} position={index + 1} />)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, usePage } from '@inertiajs/react'
|
||||
import { Link, router, usePage } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import { postAcademyAction, trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics'
|
||||
|
||||
function CourseBreadcrumbs({ items = [] }) {
|
||||
if (!items.length) return null
|
||||
@@ -197,10 +198,15 @@ function SectionBlock({ section, isActive = false }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [], pricingUrl }) {
|
||||
export default function AcademyCoursesShow({ seo, course, sections = [], unsectionedLessons = [], pricingUrl, startUrl = null, interaction = null, interactionRoutes = null, loginUrl = null, analytics = null }) {
|
||||
const flash = usePage().props.flash || {}
|
||||
useAcademyPageAnalytics(analytics)
|
||||
const cover = course?.cover_image_url || course?.cover_image || course?.teaser_image_url || course?.teaser_image || ''
|
||||
const progress = course?.progress || null
|
||||
const [liked, setLiked] = useState(Boolean(interaction?.liked))
|
||||
const [saved, setSaved] = useState(Boolean(interaction?.saved))
|
||||
const [likesCount, setLikesCount] = useState(Number(interaction?.likes_count || 0))
|
||||
const [savesCount, setSavesCount] = useState(Number(interaction?.saves_count || 0))
|
||||
|
||||
const sectionJumpItems = useMemo(
|
||||
() => [
|
||||
@@ -245,6 +251,63 @@ export default function AcademyCoursesShow({ seo, course, sections = [], unsecti
|
||||
return () => observer.disconnect()
|
||||
}, [sectionJumpItems])
|
||||
|
||||
const requireLogin = () => {
|
||||
if (loginUrl && typeof window !== 'undefined') {
|
||||
window.location.href = loginUrl
|
||||
}
|
||||
}
|
||||
|
||||
const startCourse = () => {
|
||||
if (!startUrl) {
|
||||
requireLogin()
|
||||
return
|
||||
}
|
||||
|
||||
router.post(startUrl)
|
||||
}
|
||||
|
||||
const toggleLike = async () => {
|
||||
if (!interactionRoutes?.like || !analytics?.contentType || !analytics?.contentId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (analytics?.isGuest) {
|
||||
requireLogin()
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await postAcademyAction(interactionRoutes.like, {
|
||||
content_type: analytics.contentType,
|
||||
content_id: analytics.contentId,
|
||||
})
|
||||
|
||||
if (payload?.liked !== undefined) {
|
||||
setLiked(Boolean(payload.liked))
|
||||
setLikesCount(Number(payload.likes_count || 0))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSave = async () => {
|
||||
if (!interactionRoutes?.save || !analytics?.contentType || !analytics?.contentId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (analytics?.isGuest) {
|
||||
requireLogin()
|
||||
return
|
||||
}
|
||||
|
||||
const payload = await postAcademyAction(interactionRoutes.save, {
|
||||
content_type: analytics.contentType,
|
||||
content_id: analytics.contentId,
|
||||
})
|
||||
|
||||
if (payload?.saved !== undefined) {
|
||||
setSaved(Boolean(payload.saved))
|
||||
setSavesCount(Number(payload.saves_count || 0))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#0f172a_0%,_#111827_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title={course?.title} description={course?.excerpt || course?.description} />
|
||||
@@ -273,6 +336,13 @@ export default function AcademyCoursesShow({ seo, course, sections = [], unsecti
|
||||
{course?.subtitle ? <p className="mt-4 text-sm font-semibold uppercase tracking-[0.24em] text-amber-100/90">{course.subtitle}</p> : null}
|
||||
<p className="mt-5 max-w-3xl text-base leading-8 text-slate-300 md:text-lg">{course?.excerpt || course?.description}</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button type="button" onClick={startCourse} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100">{progress?.percent ? 'Continue course' : 'Start course'}</button>
|
||||
<button type="button" onClick={toggleLike} className="rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]">{liked ? `Liked · ${likesCount}` : `Like · ${likesCount}`}</button>
|
||||
<button type="button" onClick={toggleSave} className="rounded-full border border-white/10 bg-white/[0.06] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]">{saved ? `Saved · ${savesCount}` : `Save · ${savesCount}`}</button>
|
||||
<Link href={pricingUrl} onClick={() => trackUpgradeClick(analytics, { source: 'academy_course_header' })} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100">See plans</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 overflow-hidden rounded-[32px] border border-white/10 bg-slate-950/80 shadow-[0_24px_60px_rgba(2,6,23,0.32)]">
|
||||
{cover ? (
|
||||
<img src={cover} alt="" aria-hidden="true" className="w-full object-contain" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Link } from '@inertiajs/react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
import { trackUpgradeClick, useAcademyPageAnalytics } from '../../lib/academyAnalytics'
|
||||
|
||||
function academyHref(section, slug) {
|
||||
return `/academy/${section}/${encodeURIComponent(slug)}`
|
||||
@@ -39,7 +40,9 @@ function FeaturedCourseCard({ course }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges }) {
|
||||
export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, stats, featuredCourses, featuredLessons, featuredPrompts, featuredChallenges, analytics }) {
|
||||
useAcademyPageAnalytics(analytics)
|
||||
|
||||
const jsonLd = [{
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
@@ -64,7 +67,7 @@ export default function AcademyIndex({ seo, pricingUrl, links, featureFlags, sta
|
||||
<Link href={links.courses} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">Browse courses</Link>
|
||||
<Link href={links.lessons} className="rounded-full border border-amber-300/25 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:border-amber-300/40 hover:bg-amber-300/18">Browse lessons</Link>
|
||||
<Link href={links.prompts} className="rounded-full border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]">Open prompt library</Link>
|
||||
<Link href={pricingUrl} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">See plans</Link>
|
||||
<Link href={pricingUrl} onClick={() => trackUpgradeClick(analytics, { source: 'academy_home_hero' })} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">See plans</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -74,13 +74,27 @@ function searchResultContentType(pageType) {
|
||||
return null
|
||||
}
|
||||
|
||||
function promptPreviewAsset(item) {
|
||||
const full = item?.preview_image || ''
|
||||
const thumb = item?.preview_image_thumb || full
|
||||
|
||||
if (!thumb) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
src: thumb,
|
||||
srcSet: item?.preview_image_srcset || '',
|
||||
}
|
||||
}
|
||||
|
||||
function PromptLibraryHero({ title, description, items, pricingUrl, totalCount }) {
|
||||
const featuredImages = (items || [])
|
||||
.map((item) => item?.preview_image)
|
||||
.map((item) => promptPreviewAsset(item))
|
||||
.filter(Boolean)
|
||||
.slice(0, 3)
|
||||
|
||||
const primaryImage = featuredImages[0] || ''
|
||||
const primaryImage = featuredImages[0] || null
|
||||
const supportingImages = featuredImages.slice(1, 3)
|
||||
|
||||
return (
|
||||
@@ -119,14 +133,14 @@ function PromptLibraryHero({ title, description, items, pricingUrl, totalCount }
|
||||
{primaryImage ? (
|
||||
<>
|
||||
<div className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-[16/10]">
|
||||
<img src={primaryImage} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
<img src={primaryImage.src} srcSet={primaryImage.srcSet || undefined} sizes="(max-width: 1279px) calc(100vw - 4rem), 420px" alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
|
||||
{supportingImages.length ? (
|
||||
<div className={`grid gap-3 ${supportingImages.length === 1 ? 'grid-cols-1' : 'grid-cols-2'}`}>
|
||||
{supportingImages.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.18)] aspect-square">
|
||||
<img src={image} alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
<div key={`${image.src}-${index}`} className="overflow-hidden rounded-[28px] border border-white/10 bg-black/20 shadow-[0_18px_45px_rgba(2,6,23,0.18)] aspect-square">
|
||||
<img src={image.src} srcSet={image.srcSet || undefined} sizes="(max-width: 1279px) calc(50vw - 2rem), 200px" alt="" aria-hidden="true" className="h-full w-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -145,7 +159,8 @@ function PromptLibraryHero({ title, description, items, pricingUrl, totalCount }
|
||||
|
||||
function AcademyCard({ pageType, item, analytics, searchContext, position }) {
|
||||
const lessonSeries = String(item?.series_name || '').trim()
|
||||
const promptPreviewImage = item?.preview_image || ''
|
||||
const promptPreviewImage = item?.preview_image_thumb || item?.preview_image || ''
|
||||
const promptPreviewSrcSet = item?.preview_image_srcset || ''
|
||||
const contentType = searchResultContentType(pageType)
|
||||
const href = itemHref(pageType, item)
|
||||
const trackSearchClick = () => {
|
||||
@@ -173,7 +188,7 @@ function AcademyCard({ pageType, item, analytics, searchContext, position }) {
|
||||
className="group overflow-hidden rounded-[30px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.92),rgba(7,11,18,0.96))] shadow-[0_20px_50px_rgba(2,6,23,0.18)] transition hover:border-sky-300/25 hover:bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(10,15,26,0.98))]"
|
||||
>
|
||||
<div className="relative aspect-[16/11] overflow-hidden bg-[linear-gradient(135deg,rgba(56,189,248,0.18),rgba(17,24,39,0.94))]">
|
||||
{promptPreviewImage ? <img src={promptPreviewImage} alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
{promptPreviewImage ? <img src={promptPreviewImage} srcSet={promptPreviewSrcSet || undefined} sizes="(max-width: 767px) calc(100vw - 2rem), (max-width: 1279px) calc(50vw - 2rem), 420px" alt="" aria-hidden="true" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.04]" /> : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(2,6,23,0.72))]" />
|
||||
<div className="absolute left-4 top-4 flex flex-wrap gap-2">
|
||||
<span className="rounded-full border border-white/10 bg-black/30 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-[#fff0ea]">Prompt template</span>
|
||||
@@ -260,6 +275,7 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
const [pagination, setPagination] = React.useState({
|
||||
currentPage: Number(items?.current_page || 1),
|
||||
lastPage: Number(items?.last_page || 1),
|
||||
prevPageUrl: items?.prev_page_url || null,
|
||||
nextPageUrl: items?.next_page_url || null,
|
||||
})
|
||||
const [loadingMore, setLoadingMore] = React.useState(false)
|
||||
@@ -270,12 +286,14 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
setPagination({
|
||||
currentPage: Number(items?.current_page || 1),
|
||||
lastPage: Number(items?.last_page || 1),
|
||||
prevPageUrl: items?.prev_page_url || null,
|
||||
nextPageUrl: items?.next_page_url || null,
|
||||
})
|
||||
setLoadingMore(false)
|
||||
}, [initialItems, items?.current_page, items?.last_page, items?.next_page_url, pageType])
|
||||
}, [initialItems, items?.current_page, items?.last_page, items?.next_page_url, items?.prev_page_url, pageType])
|
||||
|
||||
const hasMorePages = pageType === 'prompts' && pagination.currentPage < pagination.lastPage && Boolean(pagination.nextPageUrl)
|
||||
const hasFallbackPagination = pageType === 'prompts' && pagination.lastPage > 1
|
||||
|
||||
const loadMore = React.useCallback(async () => {
|
||||
if (pageType !== 'prompts' || loadingMore || !pagination.nextPageUrl) {
|
||||
@@ -292,6 +310,7 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
setPagination({
|
||||
currentPage: Number(payload?.current_page || pagination.currentPage),
|
||||
lastPage: Number(payload?.last_page || pagination.lastPage),
|
||||
prevPageUrl: payload?.prev_page_url || pagination.prevPageUrl,
|
||||
nextPageUrl: payload?.next_page_url || null,
|
||||
})
|
||||
} catch {
|
||||
@@ -299,7 +318,7 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
} finally {
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [loadingMore, pageType, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl])
|
||||
}, [loadingMore, pageType, pagination.currentPage, pagination.lastPage, pagination.nextPageUrl, pagination.prevPageUrl])
|
||||
|
||||
React.useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
@@ -355,6 +374,26 @@ export default function AcademyList({ pageType, title, description, seo, items,
|
||||
<div ref={sentinelRef} className="h-10 w-full" aria-hidden="true" />
|
||||
{loadingMore ? <div className="rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-300">Loading more prompts...</div> : null}
|
||||
{!hasMorePages && visibleItems.length > initialItems.length ? <div className="rounded-[22px] border border-white/10 bg-black/20 px-5 py-4 text-center text-sm text-slate-400">You have reached the end of the prompt library.</div> : null}
|
||||
{hasFallbackPagination ? (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-[22px] border border-white/10 bg-black/20 px-5 py-4">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Auto-load is primary. Pagination is available as a backup.</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{pagination.prevPageUrl ? (
|
||||
<Link href={pagination.prevPageUrl} preserveScroll className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
<i className="fa-solid fa-arrow-left text-[10px]" />
|
||||
Previous
|
||||
</Link>
|
||||
) : null}
|
||||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-slate-300">Page {pagination.currentPage || 1} of {pagination.lastPage || 1}</span>
|
||||
{pagination.nextPageUrl ? (
|
||||
<Link href={pagination.nextPageUrl} preserveScroll className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">
|
||||
Next
|
||||
<i className="fa-solid fa-arrow-right text-[10px]" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react'
|
||||
import SeoHead from '../../components/seo/SeoHead'
|
||||
|
||||
function PlanCard({ plan, paymentsEnabled }) {
|
||||
return (
|
||||
<article className="rounded-[30px] border border-white/10 bg-white/[0.04] p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.04em] text-white">{plan.name}</h2>
|
||||
<span className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-300">{plan.badge}</span>
|
||||
</div>
|
||||
<div className="mt-5 flex items-end gap-2">
|
||||
<span className="text-4xl font-semibold tracking-[-0.05em] text-white">{plan.price}</span>
|
||||
<span className="pb-1 text-sm text-slate-400">{plan.interval}</span>
|
||||
</div>
|
||||
<div className="mt-6 space-y-3 text-sm text-slate-300">
|
||||
{plan.features.map((feature) => (
|
||||
<div key={feature} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">{feature}</div>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" disabled className="mt-6 w-full rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 opacity-100">
|
||||
{paymentsEnabled ? 'Checkout coming next phase' : 'Payments disabled for this launch'}
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcademyPricing({ seo, plans, paymentsEnabled }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_24%),radial-gradient(circle_at_bottom_right,_rgba(251,191,36,0.16),_transparent_24%),linear-gradient(180deg,_#111827_0%,_#0f172a_100%)] px-4 py-8 sm:px-6 lg:px-8">
|
||||
<SeoHead seo={seo || {}} title="Skinbase AI Academy Pricing" description={seo?.description} />
|
||||
|
||||
<div className="mx-auto max-w-[1320px] space-y-8">
|
||||
<section className="rounded-[38px] border border-white/10 bg-black/20 p-8 md:p-10">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Plans</p>
|
||||
<h1 className="mt-4 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Choose your AI Academy plan.</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-8 text-slate-300">Start free, unlock Creator and Pro previews, and keep the billing flow disabled until Stripe and Cashier are introduced in the next phase.</p>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-5 lg:grid-cols-3">
|
||||
{plans.map((plan) => (
|
||||
<PlanCard key={plan.name} plan={plan} paymentsEnabled={paymentsEnabled} />
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
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>
|
||||
)
|
||||
}
|
||||
244
resources/js/Pages/Moderation/WorldWebStoriesIndex.jsx
Normal file
244
resources/js/Pages/Moderation/WorldWebStoriesIndex.jsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.errors?.story?.[0] || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function replacePattern(pattern, value) {
|
||||
return String(pattern || '').replace('__STORY__', String(value)).replace('__WORLD__', String(value))
|
||||
}
|
||||
|
||||
function StatusBadge({ story }) {
|
||||
const tone = story.status === 'published'
|
||||
? 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100'
|
||||
: story.status === 'archived'
|
||||
? 'border-amber-300/20 bg-amber-400/12 text-amber-100'
|
||||
: 'border-white/10 bg-white/[0.06] text-slate-200'
|
||||
|
||||
return <span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone}`}>{story.status}</span>
|
||||
}
|
||||
|
||||
export default function WorldWebStoriesIndex() {
|
||||
const { props } = usePage()
|
||||
const stories = props.stories || { data: [] }
|
||||
const endpoints = props.endpoints || {}
|
||||
const worldOptions = props.worldOptions || []
|
||||
const [filters, setFilters] = React.useState(props.filters || { q: '', status: 'all' })
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
const [busyKey, setBusyKey] = React.useState('')
|
||||
const [generator, setGenerator] = React.useState({ world_id: worldOptions[0]?.value || '', pages: 7, force: false, publish: false })
|
||||
|
||||
React.useEffect(() => {
|
||||
setFilters(props.filters || { q: '', status: 'all' })
|
||||
}, [props.filters])
|
||||
|
||||
function applyFilters(event) {
|
||||
event.preventDefault()
|
||||
router.get(endpoints.index, filters, { preserveState: true, replace: true, preserveScroll: true })
|
||||
}
|
||||
|
||||
async function performAction(key, url, method = 'POST', body = null) {
|
||||
setBusyKey(key)
|
||||
setNotice('')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(url, { method, body })
|
||||
setNotice(payload.message || 'Action completed.')
|
||||
router.reload({ only: ['stories', 'stats', 'filters'], preserveScroll: true })
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusyKey('')
|
||||
}
|
||||
}
|
||||
|
||||
async function generateDraft(event) {
|
||||
event.preventDefault()
|
||||
if (!generator.world_id) return
|
||||
|
||||
setBusyKey('generate')
|
||||
setNotice('')
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const payload = await requestJson(replacePattern(endpoints.generatePattern, generator.world_id), {
|
||||
body: {
|
||||
pages: Number(generator.pages || 7),
|
||||
force: Boolean(generator.force),
|
||||
publish: Boolean(generator.publish),
|
||||
},
|
||||
})
|
||||
|
||||
setNotice(payload.message || 'Web story generated.')
|
||||
|
||||
if (payload.story?.edit_url) {
|
||||
router.visit(payload.story.edit_url)
|
||||
return
|
||||
}
|
||||
|
||||
router.reload({ only: ['stories', 'stats'], preserveScroll: true })
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || 'Generation failed.')
|
||||
} finally {
|
||||
setBusyKey('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full pb-16 pt-8">
|
||||
<Head title="World Web Stories" />
|
||||
|
||||
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">World Web Stories</h1>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Create standalone AMP Web Stories for Skinbase Worlds, keep them self-canonical, and publish only when the story is complete and visible.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href={endpoints.create} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20">
|
||||
<i className="fa-solid fa-plus text-[10px]" />
|
||||
New story
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{[
|
||||
['Total stories', props.stats?.total || 0],
|
||||
['Published', props.stats?.published || 0],
|
||||
['Drafts', props.stats?.draft || 0],
|
||||
['Hidden', props.stats?.hidden || 0],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value).toLocaleString()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
|
||||
<input
|
||||
value={filters.q || ''}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, q: event.target.value }))}
|
||||
placeholder="Search by title, slug, or world"
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
<select
|
||||
value={filters.status || 'all'}
|
||||
onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value }))}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
|
||||
</form>
|
||||
|
||||
<form onSubmit={generateDraft} className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Generate from World</div>
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-[2fr_120px_auto_auto_auto]">
|
||||
<select
|
||||
value={generator.world_id}
|
||||
onChange={(event) => setGenerator((current) => ({ ...current, world_id: event.target.value }))}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
>
|
||||
<option value="">Select a World</option>
|
||||
{worldOptions.map((world) => (
|
||||
<option key={world.value} value={world.value}>{world.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min="5"
|
||||
max="10"
|
||||
value={generator.pages}
|
||||
onChange={(event) => setGenerator((current) => ({ ...current, pages: event.target.value }))}
|
||||
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
||||
/>
|
||||
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
||||
<input type="checkbox" checked={generator.force} onChange={(event) => setGenerator((current) => ({ ...current, force: event.target.checked }))} />
|
||||
Force
|
||||
</label>
|
||||
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
||||
<input type="checkbox" checked={generator.publish} onChange={(event) => setGenerator((current) => ({ ...current, publish: event.target.checked }))} />
|
||||
Publish
|
||||
</label>
|
||||
<button type="submit" disabled={busyKey === 'generate'} className="rounded-2xl border border-sky-300/20 bg-sky-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20 disabled:opacity-60">Generate</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{notice ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{notice}</div> : null}
|
||||
{error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
<div className="mt-8 grid gap-4 xl:grid-cols-2">
|
||||
{(stories.data || []).map((story) => (
|
||||
<article key={story.id} className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
||||
<div className="grid gap-4 md:grid-cols-[180px_1fr]">
|
||||
<div className="aspect-[3/4] bg-black/30">
|
||||
{story.poster_portrait_url ? <img src={story.poster_portrait_url} alt={story.title} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-white/20"><i className="fa-solid fa-book-open-reader text-4xl" /></div>}
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge story={story} />
|
||||
{!story.active ? <span className="inline-flex rounded-full border border-amber-300/20 bg-amber-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">inactive</span> : null}
|
||||
{story.noindex ? <span className="inline-flex rounded-full border border-rose-300/20 bg-rose-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-rose-100">noindex</span> : null}
|
||||
</div>
|
||||
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{story.title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">/{story.slug}{story.world ? ` • ${story.world.title}` : ''}</p>
|
||||
{story.excerpt ? <p className="mt-3 text-sm leading-6 text-slate-300">{story.excerpt}</p> : null}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
<span>{story.page_count} pages</span>
|
||||
{story.published_at ? <span>{new Date(story.published_at).toLocaleDateString()}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<Link href={replacePattern(endpoints.editPattern, story.id)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Edit</Link>
|
||||
<a href={story.public_url} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Open</a>
|
||||
{story.status === 'published' ? (
|
||||
<button type="button" onClick={() => performAction(`unpublish-${story.id}`, replacePattern(endpoints.unpublishPattern, story.id))} className="inline-flex items-center gap-2 rounded-full border border-amber-300/20 bg-amber-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-amber-100 transition hover:bg-amber-400/18">Unpublish</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => performAction(`publish-${story.id}`, replacePattern(endpoints.publishPattern, story.id))} className="inline-flex items-center gap-2 rounded-full border border-emerald-300/20 bg-emerald-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100 transition hover:bg-emerald-400/18">Publish</button>
|
||||
)}
|
||||
<button type="button" onClick={() => performAction(`delete-${story.id}`, replacePattern(endpoints.destroyPattern, story.id), 'DELETE')} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/18">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
475
resources/js/Pages/Moderation/WorldWebStoryEditor.jsx
Normal file
475
resources/js/Pages/Moderation/WorldWebStoryEditor.jsx
Normal file
@@ -0,0 +1,475 @@
|
||||
import React from 'react'
|
||||
import { Head, Link, router, useForm, usePage } from '@inertiajs/react'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') return ''
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
async function requestJson(url, { method = 'POST', body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.errors?.story?.[0] || Object.values(payload?.errors || {})?.[0]?.[0] || 'Request failed.')
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
function replacePagePattern(pattern, pageId) {
|
||||
return String(pattern || '').replace('__PAGE__', String(pageId))
|
||||
}
|
||||
|
||||
function Field({ label, children, hint }) {
|
||||
return (
|
||||
<label className="block rounded-2xl border border-white/10 bg-white/[0.04] p-4 text-sm text-slate-300">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
||||
<div className="mt-2">{children}</div>
|
||||
{hint ? <div className="mt-2 text-xs text-slate-500">{hint}</div> : null}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function StoryPageCard({ page, endpoints, onChanged }) {
|
||||
const [localPage, setLocalPage] = React.useState(page)
|
||||
const [busy, setBusy] = React.useState(false)
|
||||
const [error, setError] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
setLocalPage(page)
|
||||
}, [page])
|
||||
|
||||
async function save() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await requestJson(replacePagePattern(endpoints.pagesUpdatePattern, page.id), {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
...localPage,
|
||||
overlay_strength: Number(localPage.overlay_strength || 35),
|
||||
active: Boolean(localPage.active),
|
||||
},
|
||||
})
|
||||
onChanged()
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || 'Unable to save page.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function destroy() {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
await requestJson(replacePagePattern(endpoints.pagesDestroyPattern, page.id), { method: 'DELETE' })
|
||||
onChanged()
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || 'Unable to delete page.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Page {page.position}</div>
|
||||
<div className="mt-2 text-lg font-semibold text-white">{page.headline || 'Untitled page'}</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={save} disabled={busy} className="rounded-full border border-sky-300/20 bg-sky-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20 disabled:opacity-60">Save</button>
|
||||
<button type="button" onClick={destroy} disabled={busy} className="rounded-full border border-rose-300/20 bg-rose-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/20 disabled:opacity-60">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
<div className="mt-4 grid gap-3 xl:grid-cols-2">
|
||||
<Field label="Headline">
|
||||
<input value={localPage.headline || ''} onChange={(event) => setLocalPage((current) => ({ ...current, headline: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Caption">
|
||||
<input value={localPage.caption || ''} onChange={(event) => setLocalPage((current) => ({ ...current, caption: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Body" hint="Maximum 180 characters.">
|
||||
<textarea value={localPage.body || ''} onChange={(event) => setLocalPage((current) => ({ ...current, body: event.target.value }))} rows={3} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Alt text">
|
||||
<input value={localPage.alt_text || ''} onChange={(event) => setLocalPage((current) => ({ ...current, alt_text: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Layout">
|
||||
<select value={localPage.layout} onChange={(event) => setLocalPage((current) => ({ ...current, layout: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">
|
||||
{['cover', 'artwork', 'creator', 'mood', 'collection', 'cta'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Background type">
|
||||
<select value={localPage.background_type} onChange={(event) => setLocalPage((current) => ({ ...current, background_type: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">
|
||||
{['image', 'video', 'gradient'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Background path">
|
||||
<input value={localPage.background_path || ''} onChange={(event) => setLocalPage((current) => ({ ...current, background_path: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Mobile background path">
|
||||
<input value={localPage.background_mobile_path || ''} onChange={(event) => setLocalPage((current) => ({ ...current, background_mobile_path: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="CTA label">
|
||||
<input value={localPage.cta_label || ''} onChange={(event) => setLocalPage((current) => ({ ...current, cta_label: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="CTA URL">
|
||||
<input value={localPage.cta_url || ''} onChange={(event) => setLocalPage((current) => ({ ...current, cta_url: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Text position">
|
||||
<select value={localPage.text_position} onChange={(event) => setLocalPage((current) => ({ ...current, text_position: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">
|
||||
{['top', 'center', 'bottom'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Animation">
|
||||
<select value={localPage.animation || ''} onChange={(event) => setLocalPage((current) => ({ ...current, animation: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">
|
||||
<option value="">None</option>
|
||||
{['fade-in', 'fly-in-bottom', 'pulse', 'pan-left', 'pan-right'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Overlay strength">
|
||||
<input type="number" min="0" max="100" value={localPage.overlay_strength || 35} onChange={(event) => setLocalPage((current) => ({ ...current, overlay_strength: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Artwork ID">
|
||||
<input type="number" min="1" value={localPage.artwork_id || ''} onChange={(event) => setLocalPage((current) => ({ ...current, artwork_id: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Position">
|
||||
<input type="number" min="1" value={localPage.position || 1} onChange={(event) => setLocalPage((current) => ({ ...current, position: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Credit text">
|
||||
<input value={localPage.credit_text || ''} onChange={(event) => setLocalPage((current) => ({ ...current, credit_text: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
||||
<input type="checkbox" checked={Boolean(localPage.active)} onChange={(event) => setLocalPage((current) => ({ ...current, active: event.target.checked }))} />
|
||||
Page active
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldWebStoryEditor() {
|
||||
const { props } = usePage()
|
||||
const story = props.story
|
||||
const endpoints = props.endpoints || {}
|
||||
const worldOptions = props.worldOptions || []
|
||||
const isNew = Boolean(props.isNew)
|
||||
const [notice, setNotice] = React.useState('')
|
||||
const [error, setError] = React.useState('')
|
||||
const [pages, setPages] = React.useState(story.pages || [])
|
||||
const [newPage, setNewPage] = React.useState({
|
||||
layout: 'cover',
|
||||
background_type: 'image',
|
||||
headline: '',
|
||||
body: '',
|
||||
cta_label: '',
|
||||
cta_url: '',
|
||||
alt_text: '',
|
||||
caption: '',
|
||||
credit_text: '',
|
||||
background_path: '',
|
||||
background_mobile_path: '',
|
||||
artwork_id: '',
|
||||
text_position: 'bottom',
|
||||
overlay_strength: 35,
|
||||
animation: '',
|
||||
active: true,
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
setPages(story.pages || [])
|
||||
}, [story.pages])
|
||||
|
||||
const form = useForm({
|
||||
world_id: story.world_id || '',
|
||||
slug: story.slug || '',
|
||||
title: story.title || '',
|
||||
subtitle: story.subtitle || '',
|
||||
excerpt: story.excerpt || '',
|
||||
description: story.description || '',
|
||||
seo_title: story.seo_title || '',
|
||||
seo_description: story.seo_description || '',
|
||||
poster_portrait_path: story.poster_portrait_path || '',
|
||||
poster_square_path: story.poster_square_path || '',
|
||||
publisher_logo_path: story.publisher_logo_path || '',
|
||||
status: story.status || 'draft',
|
||||
featured: Boolean(story.featured),
|
||||
active: Boolean(story.active),
|
||||
noindex: Boolean(story.noindex),
|
||||
published_at: story.published_at || '',
|
||||
starts_at: story.starts_at || '',
|
||||
ends_at: story.ends_at || '',
|
||||
})
|
||||
|
||||
function submit(event) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setNotice('')
|
||||
|
||||
const options = {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => setNotice('Web story saved.'),
|
||||
onError: (errors) => setError(Object.values(errors)[0] || 'Save failed.'),
|
||||
}
|
||||
|
||||
if (isNew) {
|
||||
form.post(endpoints.store, options)
|
||||
return
|
||||
}
|
||||
|
||||
form.patch(endpoints.update, options)
|
||||
}
|
||||
|
||||
async function reloadEditor() {
|
||||
router.reload({ preserveScroll: true, only: ['story'] })
|
||||
}
|
||||
|
||||
async function createPage(event) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
setNotice('')
|
||||
|
||||
try {
|
||||
await requestJson(endpoints.pagesStore, {
|
||||
body: {
|
||||
...newPage,
|
||||
overlay_strength: Number(newPage.overlay_strength || 35),
|
||||
artwork_id: newPage.artwork_id ? Number(newPage.artwork_id) : null,
|
||||
active: Boolean(newPage.active),
|
||||
},
|
||||
})
|
||||
setNotice('Page created.')
|
||||
setNewPage({
|
||||
layout: 'cover',
|
||||
background_type: 'image',
|
||||
headline: '',
|
||||
body: '',
|
||||
cta_label: '',
|
||||
cta_url: '',
|
||||
alt_text: '',
|
||||
caption: '',
|
||||
credit_text: '',
|
||||
background_path: '',
|
||||
background_mobile_path: '',
|
||||
artwork_id: '',
|
||||
text_position: 'bottom',
|
||||
overlay_strength: 35,
|
||||
animation: '',
|
||||
active: true,
|
||||
})
|
||||
reloadEditor()
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || 'Unable to create page.')
|
||||
}
|
||||
}
|
||||
|
||||
async function performStoryAction(url) {
|
||||
setError('')
|
||||
setNotice('')
|
||||
try {
|
||||
const payload = await requestJson(url)
|
||||
setNotice(payload.message || 'Action completed.')
|
||||
reloadEditor()
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || 'Action failed.')
|
||||
}
|
||||
}
|
||||
|
||||
async function reorder(pageId, direction) {
|
||||
const sorted = [...pages].sort((left, right) => left.position - right.position)
|
||||
const currentIndex = sorted.findIndex((page) => page.id === pageId)
|
||||
const targetIndex = currentIndex + direction
|
||||
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= sorted.length) return
|
||||
|
||||
const next = [...sorted]
|
||||
;[next[currentIndex], next[targetIndex]] = [next[targetIndex], next[currentIndex]]
|
||||
|
||||
try {
|
||||
await requestJson(endpoints.pagesReorder, {
|
||||
body: { page_ids: next.map((page) => page.id) },
|
||||
})
|
||||
reloadEditor()
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || 'Unable to reorder pages.')
|
||||
}
|
||||
}
|
||||
|
||||
async function generateFromWorld() {
|
||||
if (!form.data.world_id) return
|
||||
try {
|
||||
const payload = await requestJson(endpoints.generateFromWorldPattern.replace('__WORLD__', String(form.data.world_id)), {
|
||||
body: { force: true, pages: Math.max(5, pages.length || 7) },
|
||||
})
|
||||
setNotice(payload.message || 'Draft regenerated from World.')
|
||||
if (payload.story?.edit_url) {
|
||||
router.visit(payload.story.edit_url)
|
||||
return
|
||||
}
|
||||
reloadEditor()
|
||||
} catch (requestError) {
|
||||
setError(requestError.message || 'Generation failed.')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full pb-16 pt-8">
|
||||
<Head title={isNew ? 'New World Web Story' : `Edit ${story.title}`} />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-white">{isNew ? 'Create World Web Story' : story.title}</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-300">Build a standalone AMP story companion for a Skinbase World without changing the canonical World route.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href={endpoints.index} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Back</Link>
|
||||
{!isNew && story.public_url ? <a href={story.public_url} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Open story</a> : null}
|
||||
{!isNew ? <button type="button" onClick={() => performStoryAction(endpoints.publish)} className="rounded-full border border-emerald-300/20 bg-emerald-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100 transition hover:bg-emerald-400/18">Publish</button> : null}
|
||||
{!isNew ? <button type="button" onClick={() => performStoryAction(endpoints.unpublish)} className="rounded-full border border-amber-300/20 bg-amber-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-amber-100 transition hover:bg-amber-400/18">Unpublish</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{notice}</div> : null}
|
||||
{error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||
|
||||
<form onSubmit={submit} className="mt-6 rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Field label="Related World">
|
||||
<select value={form.data.world_id} onChange={(event) => form.setData('world_id', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">
|
||||
<option value="">No related World</option>
|
||||
{worldOptions.map((world) => <option key={world.value} value={world.value}>{world.label}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Slug">
|
||||
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Title">
|
||||
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Subtitle">
|
||||
<input value={form.data.subtitle} onChange={(event) => form.setData('subtitle', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Excerpt">
|
||||
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={3} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Description">
|
||||
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} rows={3} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="SEO title">
|
||||
<input value={form.data.seo_title} onChange={(event) => form.setData('seo_title', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="SEO description">
|
||||
<textarea value={form.data.seo_description} onChange={(event) => form.setData('seo_description', event.target.value)} rows={3} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Poster portrait path">
|
||||
<input value={form.data.poster_portrait_path} onChange={(event) => form.setData('poster_portrait_path', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Poster square path">
|
||||
<input value={form.data.poster_square_path} onChange={(event) => form.setData('poster_square_path', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Publisher logo path">
|
||||
<input value={form.data.publisher_logo_path} onChange={(event) => form.setData('publisher_logo_path', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">
|
||||
{['draft', 'published', 'archived'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Starts at">
|
||||
<input type="datetime-local" value={form.data.starts_at ? form.data.starts_at.slice(0, 16) : ''} onChange={(event) => form.setData('starts_at', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
<Field label="Ends at">
|
||||
<input type="datetime-local" value={form.data.ends_at ? form.data.ends_at.slice(0, 16) : ''} onChange={(event) => form.setData('ends_at', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-slate-300"><input type="checkbox" checked={Boolean(form.data.featured)} onChange={(event) => form.setData('featured', event.target.checked)} /> Featured</label>
|
||||
<label className="flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-slate-300"><input type="checkbox" checked={Boolean(form.data.active)} onChange={(event) => form.setData('active', event.target.checked)} /> Active</label>
|
||||
<label className="flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-slate-300"><input type="checkbox" checked={Boolean(form.data.noindex)} onChange={(event) => form.setData('noindex', event.target.checked)} /> Noindex</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20 disabled:opacity-60">Save story</button>
|
||||
{!isNew ? <button type="button" onClick={generateFromWorld} className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Regenerate from World</button> : null}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{!isNew ? (
|
||||
<>
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Validation</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">Publish only when poster, logo, page count, alt text, and CTA rules are satisfied.</p>
|
||||
</div>
|
||||
<div className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${story.validation?.valid ? 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100' : 'border-amber-300/20 bg-amber-400/12 text-amber-100'}`}>
|
||||
{story.validation?.valid ? 'Ready to publish' : 'Needs fixes'}
|
||||
</div>
|
||||
</div>
|
||||
{(story.validation?.errors || []).length > 0 ? (
|
||||
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-4 text-sm text-amber-50">
|
||||
<ul className="space-y-2">
|
||||
{(story.validation.errors || []).map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="mt-8 rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Story pages</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">Keep each page short, visual, and clearly tied back to the World narrative.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={createPage} className="mt-6 grid gap-3 xl:grid-cols-2">
|
||||
<Field label="New page headline"><input value={newPage.headline} onChange={(event) => setNewPage((current) => ({ ...current, headline: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" /></Field>
|
||||
<Field label="New page caption"><input value={newPage.caption} onChange={(event) => setNewPage((current) => ({ ...current, caption: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" /></Field>
|
||||
<Field label="New page body"><textarea value={newPage.body} onChange={(event) => setNewPage((current) => ({ ...current, body: event.target.value }))} rows={3} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" /></Field>
|
||||
<Field label="Alt text"><input value={newPage.alt_text} onChange={(event) => setNewPage((current) => ({ ...current, alt_text: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" /></Field>
|
||||
<Field label="Layout"><select value={newPage.layout} onChange={(event) => setNewPage((current) => ({ ...current, layout: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">{['cover', 'artwork', 'creator', 'mood', 'collection', 'cta'].map((value) => <option key={value} value={value}>{value}</option>)}</select></Field>
|
||||
<Field label="Background type"><select value={newPage.background_type} onChange={(event) => setNewPage((current) => ({ ...current, background_type: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">{['image', 'video', 'gradient'].map((value) => <option key={value} value={value}>{value}</option>)}</select></Field>
|
||||
<Field label="Background path"><input value={newPage.background_path} onChange={(event) => setNewPage((current) => ({ ...current, background_path: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" /></Field>
|
||||
<Field label="Mobile background path"><input value={newPage.background_mobile_path} onChange={(event) => setNewPage((current) => ({ ...current, background_mobile_path: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" /></Field>
|
||||
<div className="xl:col-span-2 flex justify-end">
|
||||
<button type="submit" className="rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20">Add page</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{pages.sort((left, right) => left.position - right.position).map((page) => (
|
||||
<div key={page.id}>
|
||||
<div className="mb-2 flex justify-end gap-2">
|
||||
<button type="button" onClick={() => reorder(page.id, -1)} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Move up</button>
|
||||
<button type="button" onClick={() => reorder(page.id, 1)} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Move down</button>
|
||||
</div>
|
||||
<StoryPageCard page={page} endpoints={endpoints} onChanged={reloadEditor} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -584,7 +584,7 @@ function stripHtml(value) {
|
||||
return String(value || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
const NEWS_NEW_TAG_LIMIT = 12
|
||||
const NEWS_NEW_TAG_LIMIT = 30
|
||||
|
||||
function slugifyNewsTitle(value) {
|
||||
return String(value || '')
|
||||
@@ -627,7 +627,6 @@ function buildSubmitPayload(data) {
|
||||
meta_title: String(data.meta_title || ''),
|
||||
meta_description: String(data.meta_description || ''),
|
||||
meta_keywords: String(data.meta_keywords || ''),
|
||||
canonical_url: String(data.canonical_url || '').trim(),
|
||||
og_title: String(data.og_title || ''),
|
||||
og_description: String(data.og_description || ''),
|
||||
og_image: String(data.og_image || '').trim(),
|
||||
@@ -678,7 +677,6 @@ function buildInitialFormData(article, defaultAuthor, typeOptions, oldInput = {}
|
||||
meta_title: String(getDraftValue(oldInput, 'meta_title', article.meta_title || '')),
|
||||
meta_description: String(getDraftValue(oldInput, 'meta_description', article.meta_description || '')),
|
||||
meta_keywords: String(getDraftValue(oldInput, 'meta_keywords', article.meta_keywords || '')),
|
||||
canonical_url: String(getDraftValue(oldInput, 'canonical_url', article.canonical_url || '')),
|
||||
og_title: String(getDraftValue(oldInput, 'og_title', article.og_title || '')),
|
||||
og_description: String(getDraftValue(oldInput, 'og_description', article.og_description || '')),
|
||||
og_image: String(getDraftValue(oldInput, 'og_image', article.og_image || '')),
|
||||
@@ -756,6 +754,25 @@ function normalizeImportedTagList(value) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function normalizeImportedDateTime(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const dateTimeMatch = raw.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?$/)
|
||||
if (dateTimeMatch) {
|
||||
return dateTimeMatch[2] ? `${dateTimeMatch[1]}T${dateTimeMatch[2]}` : dateTimeMatch[1]
|
||||
}
|
||||
|
||||
const parsed = new Date(raw)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return raw
|
||||
}
|
||||
|
||||
const pad = (input) => String(input).padStart(2, '0')
|
||||
|
||||
return `${parsed.getFullYear()}-${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())}T${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`
|
||||
}
|
||||
|
||||
function parseStructuredNewsImport(rawValue, context) {
|
||||
const parsed = JSON.parse(String(rawValue || '').trim())
|
||||
const categoryOptions = Array.isArray(context.categoryOptions) ? context.categoryOptions : []
|
||||
@@ -782,11 +799,13 @@ function parseStructuredNewsImport(rawValue, context) {
|
||||
applyString('excerpt')
|
||||
applyString('content')
|
||||
applyString('cover_image')
|
||||
applyString('published_at')
|
||||
if (parsed.published_at != null) {
|
||||
next.published_at = normalizeImportedDateTime(parsed.published_at)
|
||||
applied.push('published_at')
|
||||
}
|
||||
applyString('meta_title')
|
||||
applyString('meta_description')
|
||||
applyString('meta_keywords')
|
||||
applyString('canonical_url')
|
||||
applyString('og_title')
|
||||
applyString('og_description')
|
||||
applyString('og_image')
|
||||
@@ -868,16 +887,190 @@ function parseStructuredNewsImport(rawValue, context) {
|
||||
}
|
||||
}
|
||||
|
||||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
|
||||
let newsMarkdownTurndown = null
|
||||
let newsMarkdownTurndownPromise = null
|
||||
|
||||
async function loadNewsMarkdownTurndown() {
|
||||
if (newsMarkdownTurndown) {
|
||||
return newsMarkdownTurndown
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!newsMarkdownTurndownPromise) {
|
||||
newsMarkdownTurndownPromise = import('turndown')
|
||||
.then(({ default: TurndownService }) => new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
bulletListMarker: '-',
|
||||
emDelimiter: '*',
|
||||
}))
|
||||
.then((service) => {
|
||||
newsMarkdownTurndown = service
|
||||
return service
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
return newsMarkdownTurndownPromise
|
||||
}
|
||||
|
||||
function findNewsOptionById(options, value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) return null
|
||||
|
||||
return (Array.isArray(options) ? options : []).find((option) => String(option.id ?? option.value ?? '').trim() === normalized) || null
|
||||
}
|
||||
|
||||
function findNewsTagsByIds(options, ids) {
|
||||
const idSet = new Set((Array.isArray(ids) ? ids : []).map((id) => Number(id)))
|
||||
|
||||
return (Array.isArray(options) ? options : [])
|
||||
.filter((option) => idSet.has(Number(option.id)))
|
||||
.map((option) => ({
|
||||
id: Number(option.id),
|
||||
name: String(option.name || option.label || ''),
|
||||
slug: String(option.slug || ''),
|
||||
}))
|
||||
}
|
||||
|
||||
function buildStructuredPlainTextExport(data) {
|
||||
const lines = []
|
||||
|
||||
if (data.title) lines.push(`Title: ${data.title}`)
|
||||
if (data.excerpt) lines.push(`Excerpt: ${data.excerpt}`)
|
||||
if (data.date) lines.push(`Date: ${data.date}`)
|
||||
if (data.category) lines.push(`Category: ${data.category}`)
|
||||
|
||||
if (data.body) {
|
||||
lines.push('')
|
||||
lines.push('Body:')
|
||||
lines.push(data.body)
|
||||
}
|
||||
|
||||
return lines.join('\n').trim()
|
||||
}
|
||||
|
||||
function convertNewsHtmlToMarkdown(value) {
|
||||
const html = String(value || '').trim()
|
||||
if (!html) return ''
|
||||
|
||||
if (!newsMarkdownTurndown) {
|
||||
return stripHtml(html)
|
||||
}
|
||||
|
||||
return newsMarkdownTurndown.turndown(html).trim()
|
||||
}
|
||||
|
||||
function buildNewsMarkdownExport(data) {
|
||||
const lines = []
|
||||
|
||||
if (data.title) {
|
||||
lines.push(`# ${data.title}`)
|
||||
}
|
||||
|
||||
if (data.excerpt) {
|
||||
lines.push(data.excerpt)
|
||||
}
|
||||
|
||||
if (data.date) {
|
||||
lines.push(`- Date: ${data.date}`)
|
||||
}
|
||||
|
||||
if (data.category) {
|
||||
lines.push(`- Category: ${data.category}`)
|
||||
}
|
||||
|
||||
const bodyMarkdown = convertNewsHtmlToMarkdown(data.body_html)
|
||||
if (bodyMarkdown) {
|
||||
lines.push(bodyMarkdown)
|
||||
}
|
||||
|
||||
return lines.join('\n\n').trim()
|
||||
}
|
||||
|
||||
function buildNewsExportPayloads(data, context = {}) {
|
||||
const normalized = buildSubmitPayload(data || {})
|
||||
const category = findNewsOptionById(context.categoryOptions, normalized.category_id)
|
||||
const existingTags = findNewsTagsByIds(context.tagOptions, normalized.tag_ids)
|
||||
const author = context.author || null
|
||||
|
||||
const full = {
|
||||
title: normalized.title,
|
||||
slug: normalized.slug,
|
||||
excerpt: normalized.excerpt,
|
||||
content: normalized.content,
|
||||
cover_image: normalized.cover_image,
|
||||
type: normalized.type,
|
||||
category_id: normalized.category_id,
|
||||
category: category?.name ?? category?.label ?? '',
|
||||
category_slug: category?.slug ?? '',
|
||||
author_id: normalized.author_id,
|
||||
author_name: author?.title ?? author?.name ?? '',
|
||||
editorial_status: normalized.editorial_status,
|
||||
published_at: normalized.published_at,
|
||||
is_featured: normalized.is_featured,
|
||||
is_pinned: normalized.is_pinned,
|
||||
comments_enabled: normalized.comments_enabled,
|
||||
tags: [
|
||||
...existingTags,
|
||||
...normalized.new_tag_names.map((name) => ({ name, slug: '' })),
|
||||
],
|
||||
tag_names: [
|
||||
...existingTags.map((tag) => tag.name),
|
||||
...normalized.new_tag_names,
|
||||
],
|
||||
tag_ids: normalized.tag_ids,
|
||||
new_tag_names: normalized.new_tag_names,
|
||||
meta_title: normalized.meta_title,
|
||||
meta_description: normalized.meta_description,
|
||||
meta_keywords: normalized.meta_keywords,
|
||||
og_title: normalized.og_title,
|
||||
og_description: normalized.og_description,
|
||||
og_image: normalized.og_image,
|
||||
relations: normalized.relations,
|
||||
}
|
||||
|
||||
const structured = {
|
||||
title: normalized.title,
|
||||
excerpt: normalized.excerpt,
|
||||
date: normalized.published_at,
|
||||
body: stripHtml(normalized.content),
|
||||
category: category?.name ?? category?.label ?? '',
|
||||
}
|
||||
|
||||
const markdown = {
|
||||
title: normalized.title,
|
||||
excerpt: normalized.excerpt,
|
||||
date: normalized.published_at,
|
||||
category: category?.name ?? category?.label ?? '',
|
||||
body_html: normalized.content,
|
||||
}
|
||||
|
||||
return {
|
||||
full: JSON.stringify(full, null, 2),
|
||||
structured: JSON.stringify(structured, null, 2),
|
||||
structuredPlain: buildStructuredPlainTextExport(structured),
|
||||
markdown: buildNewsMarkdownExport(markdown),
|
||||
markdownInput: markdown,
|
||||
}
|
||||
}
|
||||
|
||||
function JsonImportDialog({ open, value, error, onChange, onClose, onApply, exportPayloads, newTagLimit = NEWS_NEW_TAG_LIMIT }) {
|
||||
const backdropRef = useRef(null)
|
||||
const [activeImportTab, setActiveImportTab] = useState('input')
|
||||
const [copyFeedback, setCopyFeedback] = useState('')
|
||||
const [exportMode, setExportMode] = useState('full')
|
||||
const [markdownExportText, setMarkdownExportText] = useState(String(exportPayloads?.markdown || ''))
|
||||
|
||||
const importTabs = [
|
||||
{ id: 'input', label: 'Input', description: 'Paste JSON and apply it to the editor.' },
|
||||
{ id: 'structure', label: 'Structure example', description: 'A working example of the expected payload.' },
|
||||
{ id: 'docs', label: 'Documentation', description: 'Field notes and mapping rules.' },
|
||||
{ id: 'prompts', label: 'AI prompts', description: 'Prompt examples for generating structured news.' },
|
||||
{ id: 'export', label: 'Export', description: 'Copy the current article out as JSON, text, or Markdown.' },
|
||||
]
|
||||
|
||||
const structureExample = {
|
||||
@@ -912,7 +1105,6 @@ function JsonImportDialog({ open, value, error, onChange, onClose, onApply, newT
|
||||
meta_title: 'Sample News Title - Skinbase Example',
|
||||
meta_description: 'This is a sample news meta description for the structured import example.',
|
||||
meta_keywords: 'sample news, structured import, editorial example',
|
||||
canonical_url: 'https://skinbase.org/news/sample-news-title',
|
||||
og_title: 'Sample News Title',
|
||||
og_description: 'This is a sample news OG description for the structured import example.',
|
||||
og_image: 'sample-news-cover.webp',
|
||||
@@ -939,7 +1131,6 @@ Recommended fields:
|
||||
- is_featured: boolean
|
||||
- is_pinned: boolean
|
||||
- meta_title, meta_description, meta_keywords
|
||||
- canonical_url
|
||||
- og_title, og_description, og_image
|
||||
- tags: array of strings or objects with name/title/label/slug
|
||||
- tag_names: array of strings
|
||||
@@ -965,7 +1156,7 @@ Transform the following article into a news payload for the editor.
|
||||
- Write content as HTML paragraphs.
|
||||
- Include 8 to 14 highly relevant tags.
|
||||
- Include category_id when possible, otherwise use category_slug or category to help matching.
|
||||
- Fill meta_title, meta_description, canonical_url, og_title, og_description, and og_image when available.
|
||||
- Fill meta_title, meta_description, og_title, og_description, and og_image when available.
|
||||
- Make comments_enabled true unless the source clearly says otherwise.
|
||||
|
||||
Input article text:
|
||||
@@ -1008,6 +1199,12 @@ Source article:
|
||||
return `flex-1 rounded-2xl border px-4 py-3 text-left transition ${active ? 'border-sky-300/25 bg-sky-400/10 text-white' : 'border-white/10 bg-white/[0.03] text-slate-400 hover:border-white/20 hover:bg-white/[0.05] hover:text-slate-200'}`
|
||||
}
|
||||
|
||||
const activeExportText = exportMode === 'structured'
|
||||
? String(exportPayloads?.structured || '')
|
||||
: exportMode === 'markdown'
|
||||
? markdownExportText
|
||||
: String(exportPayloads?.full || '')
|
||||
|
||||
const copyText = async (text, label) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(text))
|
||||
@@ -1032,6 +1229,30 @@ Source article:
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose, open])
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdownExportText(String(exportPayloads?.markdown || ''))
|
||||
}, [exportPayloads])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || activeImportTab !== 'export' || exportMode !== 'markdown') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
loadNewsMarkdownTurndown().then(() => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setMarkdownExportText(buildNewsMarkdownExport(exportPayloads?.markdownInput || {}))
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [activeImportTab, exportMode, exportPayloads, open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
@@ -1053,12 +1274,12 @@ Source article:
|
||||
>
|
||||
<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 id="news-json-import-title" className="mt-2 text-lg font-semibold text-white">Paste article JSON</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-white/65">Use this for migrations, AI-assisted drafting, or bulk handoff from another editorial system. Matching fields are applied directly to the editor.</p>
|
||||
<h3 id="news-json-import-title" className="mt-2 text-lg font-semibold text-white">Import or export article JSON</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-white/65">Use this for migrations, AI-assisted drafting, bulk handoff from another editorial system, or copying the current article into reusable JSON.</p>
|
||||
</div>
|
||||
|
||||
<div className="border-b border-white/[0.06] px-4 py-4">
|
||||
<div className="grid gap-2 md:grid-cols-4">
|
||||
<div className="grid gap-2 md:grid-cols-5">
|
||||
{importTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
@@ -1096,7 +1317,7 @@ Source article:
|
||||
<p>`is_featured`, `is_pinned`, `comments_enabled`</p>
|
||||
<p>`tags`, `tag_names`, `tag_ids`, `relations`</p>
|
||||
<p>`new_tag_names` is capped at {newTagLimit} items per article.</p>
|
||||
<p>`meta_title`, `meta_description`, `meta_keywords`, `canonical_url`, `og_title`, `og_description`, `og_image`</p>
|
||||
<p>`meta_title`, `meta_description`, `meta_keywords`, `og_title`, `og_description`, `og_image`</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1186,6 +1407,56 @@ Source article:
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeImportTab === 'export' ? (
|
||||
<div className="grid h-full min-h-0 gap-5 xl:grid-cols-[minmax(0,1.2fr)_360px]">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportMode('full')}
|
||||
className={tabButtonClass(exportMode === 'full')}
|
||||
>
|
||||
<div className="text-sm font-semibold">Full news JSON</div>
|
||||
<div className="mt-1 text-xs leading-5 text-current/70">Exports the current article with metadata, tags, and relations.</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportMode('structured')}
|
||||
className={tabButtonClass(exportMode === 'structured')}
|
||||
>
|
||||
<div className="text-sm font-semibold">Structured JSON</div>
|
||||
<div className="mt-1 text-xs leading-5 text-current/70">Exports only title, excerpt, date, body, and category.</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExportMode('markdown')}
|
||||
className={tabButtonClass(exportMode === 'markdown')}
|
||||
>
|
||||
<div className="text-sm font-semibold">Markdown</div>
|
||||
<div className="mt-1 text-xs leading-5 text-current/70">Exports the current article as Markdown with heading, summary, and body.</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
readOnly
|
||||
value={activeExportText}
|
||||
rows={18}
|
||||
className="nova-scrollbar w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 font-mono text-sm text-white outline-none"
|
||||
/>
|
||||
</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">Export options</div>
|
||||
<div className="mt-3 space-y-3 leading-6 text-slate-400">
|
||||
<p><strong className="text-slate-200">Full news JSON</strong> includes the current editable article state: slug, status, tags, metadata, and relations.</p>
|
||||
<p><strong className="text-slate-200">Structured JSON</strong> keeps the reduced handoff shape: title, excerpt, date, body, and category.</p>
|
||||
<p><strong className="text-slate-200">Markdown</strong> converts the current article body into Markdown and includes the title plus summary fields for external reuse.</p>
|
||||
<p>The export uses the live editor state, so unsaved changes are included immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{copyFeedback ? (
|
||||
@@ -1200,13 +1471,34 @@ Source article:
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply?.()}
|
||||
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110"
|
||||
>
|
||||
Apply JSON
|
||||
</button>
|
||||
{activeImportTab === 'export' ? (
|
||||
<>
|
||||
{exportMode === 'structured' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(String(exportPayloads?.structuredPlain || ''), 'Structured plain text export')}
|
||||
className="inline-flex items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.04] px-4 py-2 text-sm font-medium text-white/70 transition hover:bg-white/[0.08] hover:text-white"
|
||||
>
|
||||
Copy plain text
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(activeExportText, exportMode === 'structured' ? 'Structured export' : exportMode === 'markdown' ? 'Markdown export' : 'Full news export')}
|
||||
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110"
|
||||
>
|
||||
Copy export
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApply?.()}
|
||||
className="inline-flex items-center justify-center rounded-full border border-sky-300/25 bg-sky-400/90 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:brightness-110"
|
||||
>
|
||||
Apply JSON
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
@@ -1336,7 +1628,7 @@ export default function StudioNewsEditor() {
|
||||
const tabErrorCounts = useMemo(() => ({
|
||||
content: ['title', 'slug', 'excerpt', 'content', 'cover_image'].filter((key) => Boolean(form.errors[key])).length,
|
||||
publishing: ['type', 'category_id', 'author_id', 'editorial_status', 'published_at', 'comments_enabled'].filter((key) => Boolean(form.errors[key])).length,
|
||||
discoverability: ['tag_ids', 'new_tag_names', 'meta_title', 'meta_description', 'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image'].filter((key) => Boolean(form.errors[key])).length,
|
||||
discoverability: ['tag_ids', 'new_tag_names', 'meta_title', 'meta_description', 'meta_keywords', 'og_title', 'og_description', 'og_image'].filter((key) => Boolean(form.errors[key])).length,
|
||||
connections: ['relations'].filter((key) => Boolean(form.errors[key])).length,
|
||||
}), [form.errors])
|
||||
const overviewItems = useMemo(() => ([
|
||||
@@ -1348,6 +1640,11 @@ export default function StudioNewsEditor() {
|
||||
{ label: 'Author', done: Boolean(form.data.author_id) },
|
||||
]), [bodyWordCount, form.data.author_id, form.data.category_id, form.data.cover_image, form.data.excerpt, form.data.title])
|
||||
const completedCount = overviewItems.filter((item) => item.done).length
|
||||
const jsonExportPayloads = useMemo(() => buildNewsExportPayloads(form.data, {
|
||||
categoryOptions: props.categoryOptions,
|
||||
tagOptions: props.tagOptions,
|
||||
author: selectedAuthor,
|
||||
}), [form.data, props.categoryOptions, props.tagOptions, selectedAuthor])
|
||||
|
||||
useEffect(() => {
|
||||
const firstErrorTab = NEWS_EDITOR_TABS.find((tab) => tabErrorCounts[tab.id] > 0)
|
||||
@@ -1781,10 +2078,6 @@ export default function StudioNewsEditor() {
|
||||
<span className="text-xs leading-5 text-slate-500">Maximum 255 characters. The field now stops at the limit so it fails less often on save.</span>
|
||||
<FieldError message={form.errors.meta_keywords} />
|
||||
</label>
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Canonical URL</span>
|
||||
<input value={form.data.canonical_url} onChange={(event) => form.setData('canonical_url', event.target.value)} placeholder="https://..." className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG title</span>
|
||||
@@ -1893,6 +2186,7 @@ export default function StudioNewsEditor() {
|
||||
open={jsonImportOpen}
|
||||
value={jsonImportValue}
|
||||
error={jsonImportError}
|
||||
exportPayloads={jsonExportPayloads}
|
||||
newTagLimit={props.newsTagLimit || NEWS_NEW_TAG_LIMIT}
|
||||
onChange={(nextValue) => {
|
||||
setJsonImportValue(nextValue)
|
||||
|
||||
@@ -31,11 +31,38 @@ function statusTone(status) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildPaginationPages(current, last) {
|
||||
if (last <= 1) return [1]
|
||||
if (last <= 7) {
|
||||
return Array.from({ length: last }, (_, index) => index + 1)
|
||||
}
|
||||
|
||||
const pages = new Set([1, 2, current - 1, current, current + 1, last - 1, last])
|
||||
const sorted = [...pages]
|
||||
.filter((page) => page >= 1 && page <= last)
|
||||
.sort((left, right) => left - right)
|
||||
|
||||
const result = []
|
||||
for (let index = 0; index < sorted.length; index += 1) {
|
||||
if (index > 0 && sorted[index] - sorted[index - 1] > 1) {
|
||||
result.push('ellipsis')
|
||||
}
|
||||
result.push(sorted[index])
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export default function StudioNewsIndex() {
|
||||
const { props } = usePage()
|
||||
const items = Array.isArray(props.listing?.items) ? props.listing.items : []
|
||||
const filters = props.listing?.filters || {}
|
||||
const meta = props.listing?.meta || {}
|
||||
const currentPage = Number(meta.current_page || 1)
|
||||
const lastPage = Number(meta.last_page || 1)
|
||||
const from = Number(meta.from || 0)
|
||||
const to = Number(meta.to || 0)
|
||||
const paginationPages = buildPaginationPages(currentPage, lastPage)
|
||||
|
||||
const deleteItem = (item) => {
|
||||
if (!item?.delete_url) return
|
||||
@@ -46,11 +73,13 @@ export default function StudioNewsIndex() {
|
||||
})
|
||||
}
|
||||
|
||||
const updateFilter = (next) => {
|
||||
const updateFilter = (next, resetPage = true) => {
|
||||
const hasExplicitPage = Object.prototype.hasOwnProperty.call(next, 'page')
|
||||
|
||||
router.get('/studio/news', {
|
||||
...filters,
|
||||
...next,
|
||||
page: 1,
|
||||
page: hasExplicitPage ? next.page : (resetPage ? 1 : currentPage),
|
||||
}, {
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
@@ -94,6 +123,8 @@ export default function StudioNewsIndex() {
|
||||
status: filters.status || '',
|
||||
type: filters.type || '',
|
||||
category_id: filters.category_id || '',
|
||||
order: filters.order || '',
|
||||
direction: filters.direction || '',
|
||||
})
|
||||
}
|
||||
}}
|
||||
@@ -103,7 +134,7 @@ export default function StudioNewsIndex() {
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Status</span>
|
||||
<NovaSelect
|
||||
value={filters.status || ''}
|
||||
onChange={(value) => updateFilter({ status: value, q: filters.q || '', type: filters.type || '', category_id: filters.category_id || '' })}
|
||||
onChange={(value) => updateFilter({ status: value, q: filters.q || '', type: filters.type || '', category_id: filters.category_id || '', order: filters.order || '', direction: filters.direction || '' })}
|
||||
placeholder="All statuses"
|
||||
options={(Array.isArray(props.statusOptions) ? props.statusOptions : []).map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
@@ -113,7 +144,7 @@ export default function StudioNewsIndex() {
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Type</span>
|
||||
<NovaSelect
|
||||
value={filters.type || ''}
|
||||
onChange={(value) => updateFilter({ type: value, q: filters.q || '', status: filters.status || '', category_id: filters.category_id || '' })}
|
||||
onChange={(value) => updateFilter({ type: value, q: filters.q || '', status: filters.status || '', category_id: filters.category_id || '', order: filters.order || '', direction: filters.direction || '' })}
|
||||
placeholder="All types"
|
||||
options={(Array.isArray(props.typeOptions) ? props.typeOptions : []).map((option) => ({ value: option.value, label: option.label }))}
|
||||
searchable={false}
|
||||
@@ -123,12 +154,53 @@ export default function StudioNewsIndex() {
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</span>
|
||||
<NovaSelect
|
||||
value={filters.category_id || ''}
|
||||
onChange={(value) => updateFilter({ category_id: value, q: filters.q || '', status: filters.status || '', type: filters.type || '' })}
|
||||
onChange={(value) => updateFilter({ category_id: value, q: filters.q || '', status: filters.status || '', type: filters.type || '', order: filters.order || '', direction: filters.direction || '' })}
|
||||
placeholder="All categories"
|
||||
options={(Array.isArray(props.categoryOptions) ? props.categoryOptions : []).map((option) => ({ value: String(option.id), label: option.name }))}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Order</span>
|
||||
<NovaSelect
|
||||
value={filters.order || ''}
|
||||
onChange={(value) => updateFilter({ order: value, q: filters.q || '', status: filters.status || '', type: filters.type || '', category_id: filters.category_id || '', direction: filters.direction || '' })}
|
||||
placeholder="Order by"
|
||||
options={[
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'title', label: 'Title' },
|
||||
{ value: 'views', label: 'Views' },
|
||||
]}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Direction</span>
|
||||
<NovaSelect
|
||||
value={filters.direction || ''}
|
||||
onChange={(value) => updateFilter({ direction: value, q: filters.q || '', status: filters.status || '', type: filters.type || '', category_id: filters.category_id || '', order: filters.order || '' })}
|
||||
placeholder="Asc / Desc"
|
||||
options={[
|
||||
{ value: 'desc', label: 'Desc' },
|
||||
{ value: 'asc', label: 'Asc' },
|
||||
]}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 text-sm text-slate-300">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Per page</span>
|
||||
<NovaSelect
|
||||
value={String(filters.per_page || 15)}
|
||||
onChange={(value) => updateFilter({ per_page: value })}
|
||||
placeholder="Per page"
|
||||
options={[
|
||||
{ value: '15', label: '15 articles' },
|
||||
{ value: '30', label: '30 articles' },
|
||||
{ value: '50', label: '50 articles' },
|
||||
]}
|
||||
searchable={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-slate-400 lg:text-right">{Number(meta.total || 0).toLocaleString()} articles</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -161,6 +233,48 @@ export default function StudioNewsIndex() {
|
||||
</article>
|
||||
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-6 text-sm text-slate-400">No News articles match the current filters.</div>}
|
||||
</section>
|
||||
|
||||
{lastPage > 1 ? (
|
||||
<div className="mt-6 flex flex-col gap-3 rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-300 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-slate-400">
|
||||
Showing {from.toLocaleString()}-{to.toLocaleString()} of {Number(meta.total || 0).toLocaleString()} articles
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => updateFilter({ page: Math.max(1, currentPage - 1) }, false)}
|
||||
className="rounded-full border border-white/10 px-4 py-2 font-semibold text-white transition hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{paginationPages.map((page, index) => (
|
||||
page === 'ellipsis' ? (
|
||||
<span key={`ellipsis-${index}`} className="px-2 text-slate-500">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
type="button"
|
||||
onClick={() => updateFilter({ page }, false)}
|
||||
aria-current={page === currentPage ? 'page' : undefined}
|
||||
className={`min-w-10 rounded-full border px-3 py-2 text-sm font-semibold transition ${page === currentPage ? 'border-sky-300/20 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.03] text-white hover:bg-white/[0.06]'}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
<span className="ml-1 text-xs uppercase tracking-[0.18em] text-slate-500">Page {currentPage} of {lastPage}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={currentPage >= lastPage}
|
||||
onClick={() => updateFilter({ page: currentPage + 1 }, false)}
|
||||
className="rounded-full border border-white/10 px-4 py-2 font-semibold text-white transition hover:bg-white/[0.06] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
@@ -143,6 +143,40 @@ function RewardedContributors({ section, world }) {
|
||||
)
|
||||
}
|
||||
|
||||
function WebStoryCard({ story, worldTitle }) {
|
||||
if (!story) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-10 rounded-[28px] border border-sky-300/18 bg-[linear-gradient(135deg,rgba(14,165,233,0.12),rgba(15,23,42,0.85))] p-5">
|
||||
<div className="grid gap-5 lg:grid-cols-[220px,1fr] lg:items-center">
|
||||
<div className="overflow-hidden rounded-[24px] border border-white/10 bg-black/20 aspect-[3/4] max-w-[220px]">
|
||||
{story.poster_portrait_url ? (
|
||||
<img src={story.poster_portrait_url} alt={story.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-slate-500">
|
||||
<i className="fa-solid fa-book-open-reader text-4xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100/80">Web Story</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">Experience this World as a Web Story</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-200">Swipe through a cinematic visual preview of {worldTitle}.</p>
|
||||
{story.excerpt ? <p className="mt-3 max-w-3xl text-sm leading-6 text-slate-300">{story.excerpt}</p> : null}
|
||||
<div className="mt-5">
|
||||
<a href={story.url} className="inline-flex items-center gap-2 rounded-full border border-white/12 bg-white/[0.08] px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.16em] text-white transition hover:bg-white/[0.14]">
|
||||
View Web Story
|
||||
<i className="fa-solid fa-arrow-right" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorldShow() {
|
||||
const { props } = usePage()
|
||||
const world = props.world
|
||||
@@ -160,6 +194,7 @@ export default function WorldShow() {
|
||||
const currentEdition = props.currentEdition || null
|
||||
const previousEdition = props.previousEdition || null
|
||||
const nextEdition = props.nextEdition || null
|
||||
const webStory = props.webStory || world?.published_web_story || null
|
||||
const archiveTitle = currentEdition ? 'Previous Editions' : 'Archive Editions'
|
||||
const archiveDescription = currentEdition
|
||||
? 'Earlier editions remain public so the recurring family keeps its full history accessible.'
|
||||
@@ -265,6 +300,8 @@ export default function WorldShow() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<WebStoryCard story={webStory} worldTitle={world?.title || 'this World'} />
|
||||
|
||||
{sections.length > 0 ? sections.map((section) => <WorldSection key={section.key} section={section} />) : null}
|
||||
|
||||
<WorldChallengeEntriesRail section={linkedChallengeEntries} challengeId={linkedChallenge?.id || null} />
|
||||
|
||||
27
resources/js/components/academy/billing/AccessBadge.jsx
Normal file
27
resources/js/components/academy/billing/AccessBadge.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
|
||||
const LABELS = {
|
||||
free: 'Free',
|
||||
creator: 'Creator',
|
||||
pro: 'Pro',
|
||||
admin: 'Admin',
|
||||
}
|
||||
|
||||
const CLASSES = {
|
||||
free: 'border-white/12 bg-white/[0.06] text-slate-200',
|
||||
creator: 'border-amber-300/25 bg-amber-300/12 text-amber-100',
|
||||
pro: 'border-sky-300/25 bg-sky-300/12 text-sky-100',
|
||||
admin: 'border-emerald-300/25 bg-emerald-300/12 text-emerald-100',
|
||||
}
|
||||
|
||||
export default function AccessBadge({ tier = 'free', className = '' }) {
|
||||
const normalizedTier = typeof tier === 'string' ? tier.toLowerCase() : 'free'
|
||||
const label = LABELS[normalizedTier] || 'Free'
|
||||
const tone = CLASSES[normalizedTier] || CLASSES.free
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] ${tone} ${className}`.trim()}>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
105
resources/js/components/academy/billing/PlanCard.jsx
Normal file
105
resources/js/components/academy/billing/PlanCard.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react'
|
||||
import { Link } from '@inertiajs/react'
|
||||
import AccessBadge from './AccessBadge'
|
||||
|
||||
function ActionButton({ disabled, children, onClick, href, tone = 'primary' }) {
|
||||
const toneClass = {
|
||||
primary: 'border-sky-300/25 bg-sky-300/12 text-sky-100 hover:border-sky-300/40 hover:bg-sky-300/18',
|
||||
emerald: 'border-emerald-300/25 bg-emerald-300/10 text-emerald-100 hover:bg-emerald-300/18',
|
||||
default: 'border-white/10 bg-white/[0.05] text-white hover:border-white/20 hover:bg-white/[0.08]',
|
||||
}[tone] ?? 'border-white/10 bg-white/[0.05] text-white hover:border-white/20 hover:bg-white/[0.08]'
|
||||
|
||||
if (href) {
|
||||
return <Link href={href} className={`inline-flex w-full items-center justify-center rounded-full border px-5 py-3 text-sm font-semibold transition ${toneClass}`}>{children}</Link>
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" disabled={disabled} onClick={onClick} className={`inline-flex w-full items-center justify-center rounded-full border px-5 py-3 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60 ${toneClass}`}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PlanCard({ product, selectedPlan, currentTier, isSubscribed, activePlanKey, billingEnabled, loginHref, manageHref, onCheckout }) {
|
||||
const activeTier = typeof currentTier === 'string' ? currentTier.toLowerCase() : 'free'
|
||||
const isActivePlan = selectedPlan?.key === activePlanKey
|
||||
// Pro subscribers already have creator access — don't show a separate "switch" CTA for creator card
|
||||
const isHigherTierCovered = activeTier === 'pro' && product.tier === 'creator'
|
||||
const isPlanReady = Boolean(selectedPlan?.configured && selectedPlan?.price_id_valid)
|
||||
// User has a different active subscription (not this plan)
|
||||
const isSubscribedElsewhere = isSubscribed && !isActivePlan
|
||||
|
||||
return (
|
||||
<article className={`relative overflow-hidden rounded-[32px] border p-6 transition md:p-7 ${
|
||||
isActivePlan
|
||||
? 'border-emerald-300/25 bg-[linear-gradient(180deg,rgba(16,185,129,0.1),rgba(15,23,42,0.96))] shadow-[0_28px_90px_rgba(5,150,105,0.14)]'
|
||||
: product.featured
|
||||
? 'border-sky-300/25 bg-[linear-gradient(180deg,rgba(14,165,233,0.12),rgba(15,23,42,0.96))] shadow-[0_28px_90px_rgba(2,132,199,0.14)]'
|
||||
: 'border-white/10 bg-white/[0.04]'
|
||||
}`}>
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(255,255,255,0.45),transparent)]" />
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-400">{product.badge}</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold tracking-[-0.05em] text-white">{product.name}</h2>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-300">{product.description}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||
{isActivePlan
|
||||
? <span className="rounded-full border border-emerald-300/30 bg-emerald-300/14 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-emerald-100">Your plan</span>
|
||||
: <AccessBadge tier={product.tier} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-[24px] border border-white/10 bg-black/20 px-4 py-4">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-400">Monthly</p>
|
||||
<p className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">{selectedPlan?.price_display || '—'}</p>
|
||||
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">Billed monthly · cancel anytime</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3 text-sm text-slate-300">
|
||||
{product.features.map((feature) => (
|
||||
<div key={feature} className="flex items-start gap-2.5 rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||||
<span className="mt-px shrink-0 text-emerald-400">✓</span>
|
||||
<span>{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{/* Active plan: manage */}
|
||||
{isActivePlan ? (
|
||||
<ActionButton href={manageHref} tone="emerald">Manage subscription</ActionButton>
|
||||
) : null}
|
||||
|
||||
{/* Subscribed elsewhere: switch */}
|
||||
{isSubscribedElsewhere && !isHigherTierCovered ? (
|
||||
<ActionButton href={manageHref} tone="default">Switch to {product.name}</ActionButton>
|
||||
) : null}
|
||||
|
||||
{/* Higher tier already covers this plan */}
|
||||
{isHigherTierCovered && !isActivePlan ? (
|
||||
<p className="text-center text-xs text-slate-500">Included in your Pro plan</p>
|
||||
) : null}
|
||||
|
||||
{/* Not subscribed, not logged in */}
|
||||
{!isSubscribed && loginHref ? (
|
||||
<ActionButton href={loginHref} tone="primary">
|
||||
{billingEnabled ? `Get ${product.name}` : 'Coming soon'}
|
||||
</ActionButton>
|
||||
) : null}
|
||||
|
||||
{/* Not subscribed, logged in */}
|
||||
{!isSubscribed && !loginHref ? (
|
||||
<ActionButton
|
||||
disabled={!billingEnabled || !isPlanReady}
|
||||
onClick={() => onCheckout(selectedPlan)}
|
||||
tone="primary"
|
||||
>
|
||||
{!billingEnabled ? 'Coming soon' : isPlanReady ? `Get ${product.name} — ${selectedPlan?.price_display || ''}` : 'Not available yet'}
|
||||
</ActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
16
resources/js/components/academy/billing/UpgradeCta.jsx
Normal file
16
resources/js/components/academy/billing/UpgradeCta.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import { Link } from '@inertiajs/react'
|
||||
|
||||
export default function UpgradeCta({ title, description, primaryHref, primaryLabel, secondaryHref = null, secondaryLabel = null }) {
|
||||
return (
|
||||
<section className="rounded-[30px] border border-white/10 bg-[linear-gradient(135deg,rgba(8,47,73,0.92),rgba(30,41,59,0.94),rgba(67,20,7,0.82))] p-6 shadow-[0_24px_80px_rgba(2,6,23,0.32)] md:p-7">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-100/85">Academy Billing</p>
|
||||
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.04em] text-white">{title}</h2>
|
||||
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-200/90">{description}</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Link href={primaryHref} className="rounded-full border border-sky-300/25 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:border-sky-300/40 hover:bg-sky-300/18">{primaryLabel}</Link>
|
||||
{secondaryHref && secondaryLabel ? <Link href={secondaryHref} className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.08]">{secondaryLabel}</Link> : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -2,17 +2,23 @@ import React from 'react'
|
||||
|
||||
// ── Pagination ────────────────────────────────────────────────────────────────
|
||||
function Pagination({ meta, onPageChange }) {
|
||||
if (!meta || meta.last_page <= 1) return null
|
||||
if (!meta) return null
|
||||
|
||||
const currentPage = Number(meta.current_page || 1)
|
||||
const lastPage = meta.last_page != null ? Number(meta.last_page) : null
|
||||
const hasMore = Boolean(meta.has_more)
|
||||
|
||||
if (lastPage !== null && lastPage <= 1) return null
|
||||
if (lastPage === null && currentPage <= 1 && !hasMore) return null
|
||||
|
||||
const { current_page, last_page } = meta
|
||||
const pages = []
|
||||
|
||||
if (last_page <= 7) {
|
||||
for (let i = 1; i <= last_page; i++) pages.push(i)
|
||||
} else {
|
||||
if (lastPage !== null && lastPage <= 7) {
|
||||
for (let i = 1; i <= lastPage; i++) pages.push(i)
|
||||
} else if (lastPage !== null) {
|
||||
const around = new Set(
|
||||
[1, last_page, current_page, current_page - 1, current_page + 1].filter(
|
||||
(p) => p >= 1 && p <= last_page
|
||||
[1, lastPage, currentPage, currentPage - 1, currentPage + 1].filter(
|
||||
(p) => p >= 1 && p <= lastPage
|
||||
)
|
||||
)
|
||||
const sorted = [...around].sort((a, b) => a - b)
|
||||
@@ -28,15 +34,15 @@ function Pagination({ meta, onPageChange }) {
|
||||
className="mt-10 flex items-center justify-center gap-1 flex-wrap"
|
||||
>
|
||||
<button
|
||||
disabled={current_page <= 1}
|
||||
onClick={() => onPageChange(current_page - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
‹ Prev
|
||||
</button>
|
||||
|
||||
{pages.map((p, i) =>
|
||||
{lastPage !== null ? pages.map((p, i) =>
|
||||
p === '…' ? (
|
||||
<span key={`sep-${i}`} className="px-2 text-white/25 text-sm select-none">
|
||||
…
|
||||
@@ -44,11 +50,11 @@ function Pagination({ meta, onPageChange }) {
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => p !== current_page && onPageChange(p)}
|
||||
aria-current={p === current_page ? 'page' : undefined}
|
||||
onClick={() => p !== currentPage && onPageChange(p)}
|
||||
aria-current={p === currentPage ? 'page' : undefined}
|
||||
className={[
|
||||
'min-w-[2rem] px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
p === current_page
|
||||
p === currentPage
|
||||
? 'bg-sky-600/30 text-sky-300 ring-1 ring-sky-500/40'
|
||||
: 'text-white/50 hover:text-white hover:bg-white/[0.06]',
|
||||
].join(' ')}
|
||||
@@ -56,11 +62,15 @@ function Pagination({ meta, onPageChange }) {
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
) : (
|
||||
<span className="px-2 text-sm text-white/35">
|
||||
Page {currentPage}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
disabled={current_page >= last_page}
|
||||
onClick={() => onPageChange(current_page + 1)}
|
||||
disabled={lastPage !== null ? currentPage >= lastPage : !hasMore}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
className="px-3 py-1.5 rounded-md text-sm text-white/50 hover:text-white hover:bg-white/[0.06] disabled:opacity-25 disabled:pointer-events-none transition-colors"
|
||||
aria-label="Next page"
|
||||
>
|
||||
|
||||
@@ -11,6 +11,9 @@ const MONTH_NAMES = [
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
const YEAR_MIN = 1900
|
||||
const YEAR_MAX = 2105
|
||||
|
||||
const DAY_ABBR = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
|
||||
function pad(value) {
|
||||
@@ -40,16 +43,35 @@ function parseDatePart(value) {
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
|
||||
function splitDateTime(value) {
|
||||
if (!value) {
|
||||
return { date: '', time: '' }
|
||||
function normalizeDateTimeInput(value) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return { date: '', time: '' }
|
||||
|
||||
const match = raw.match(/^(\d{4}-\d{2}-\d{2})(?:[ T](\d{2}:\d{2})(?::\d{2})?)?(?:Z|[+-]\d{2}:?\d{2})?$/)
|
||||
if (match) {
|
||||
return {
|
||||
date: match[1],
|
||||
time: match[2] || '',
|
||||
}
|
||||
}
|
||||
|
||||
const [date = '', time = ''] = String(value).split('T')
|
||||
const parsed = new Date(raw)
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return { date: raw, time: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
date,
|
||||
time: time.slice(0, 5),
|
||||
date: toISODate(parsed),
|
||||
time: `${pad(parsed.getHours())}:${pad(parsed.getMinutes())}`,
|
||||
}
|
||||
}
|
||||
|
||||
function splitDateTime(value) {
|
||||
const normalized = normalizeDateTimeInput(value)
|
||||
|
||||
return {
|
||||
date: normalized.date,
|
||||
time: normalized.time.slice(0, 5),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +438,7 @@ export default function DateTimePicker({
|
||||
className="fixed z-[500] overflow-hidden rounded-2xl border border-white/12 bg-nova-900 shadow-2xl shadow-black/50"
|
||||
style={{ top: dropPos.top, left: dropPos.left, width: dropPos.width }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-3 pt-3">
|
||||
<div className="flex items-center justify-between gap-2 px-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prevMonth}
|
||||
@@ -428,7 +450,32 @@ export default function DateTimePicker({
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span className="text-sm font-semibold text-white">{MONTH_NAMES[viewMonth]} {viewYear}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
|
||||
<span className="whitespace-nowrap text-sm font-semibold text-white">{MONTH_NAMES[viewMonth]}</span>
|
||||
<div className="flex items-center gap-1 rounded-xl border border-white/10 bg-white/[0.04] px-1 py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewYear((current) => Math.max(YEAR_MIN, current - 1))}
|
||||
disabled={viewYear <= YEAR_MIN}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white/8 hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
|
||||
aria-label="Previous year"
|
||||
>
|
||||
<i className="fa-solid fa-minus text-[11px]" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<span className="min-w-[72px] px-2 text-center text-sm font-semibold text-white">{viewYear}</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewYear((current) => Math.min(YEAR_MAX, current + 1))}
|
||||
disabled={viewYear >= YEAR_MAX}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-slate-400 transition-all hover:bg-white/8 hover:text-white disabled:cursor-not-allowed disabled:opacity-30"
|
||||
aria-label="Next year"
|
||||
>
|
||||
<i className="fa-solid fa-plus text-[11px]" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
||||
296
resources/js/lib/academyAnalytics.js
Normal file
296
resources/js/lib/academyAnalytics.js
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const VISITOR_STORAGE_KEY = 'academy.analytics.visitor-id'
|
||||
const VISITOR_COOKIE_NAME = 'academy_visitor_id'
|
||||
const ONCE_PREFIX = 'academy.analytics.once:'
|
||||
|
||||
function getCsrfToken() {
|
||||
if (typeof document === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||
}
|
||||
|
||||
function getCookieValue(name) {
|
||||
if (typeof document === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
|
||||
return match ? decodeURIComponent(match[1]) : ''
|
||||
}
|
||||
|
||||
function generateVisitorId() {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
|
||||
return `academy-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function ensureVisitorId() {
|
||||
if (typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
let visitorId = ''
|
||||
|
||||
try {
|
||||
visitorId = window.localStorage.getItem(VISITOR_STORAGE_KEY) || ''
|
||||
} catch {
|
||||
visitorId = ''
|
||||
}
|
||||
|
||||
if (!visitorId) {
|
||||
visitorId = getCookieValue(VISITOR_COOKIE_NAME)
|
||||
}
|
||||
|
||||
if (!visitorId) {
|
||||
visitorId = generateVisitorId()
|
||||
}
|
||||
|
||||
try {
|
||||
window.localStorage.setItem(VISITOR_STORAGE_KEY, visitorId)
|
||||
} catch {
|
||||
// Ignore storage failures and continue.
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.cookie = `${VISITOR_COOKIE_NAME}=${encodeURIComponent(visitorId)}; path=/; max-age=31536000; SameSite=Lax`
|
||||
}
|
||||
|
||||
return visitorId
|
||||
}
|
||||
|
||||
function buildPayload(payload = {}) {
|
||||
return {
|
||||
...payload,
|
||||
visitor_id: payload.visitor_id || ensureVisitorId(),
|
||||
url: payload.url || (typeof window !== 'undefined' ? window.location.href : null),
|
||||
_token: payload._token || getCsrfToken(),
|
||||
}
|
||||
}
|
||||
|
||||
function markOnce(onceKey) {
|
||||
if (!onceKey || typeof window === 'undefined') {
|
||||
return false
|
||||
}
|
||||
|
||||
const storageKey = `${ONCE_PREFIX}${onceKey}`
|
||||
|
||||
try {
|
||||
if (window.sessionStorage.getItem(storageKey)) {
|
||||
return true
|
||||
}
|
||||
|
||||
window.sessionStorage.setItem(storageKey, '1')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function postAcademyAction(url, payload = {}) {
|
||||
if (!url || typeof window === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(buildPayload(payload)),
|
||||
}).catch(() => null)
|
||||
|
||||
if (!response?.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const responseContentType = response.headers.get('content-type') || ''
|
||||
if (!responseContentType.includes('application/json')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return response.json().catch(() => null)
|
||||
}
|
||||
|
||||
export function trackAcademyEvent(eventType, contentType, contentId, metadata = {}, options = {}) {
|
||||
if (!eventType || !options?.url || typeof window === 'undefined') {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
if (options.onceKey && markOnce(options.onceKey)) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
|
||||
const payload = buildPayload({
|
||||
event_type: eventType,
|
||||
content_type: contentType || null,
|
||||
content_id: contentId || null,
|
||||
metadata,
|
||||
route_name: options.pageName || null,
|
||||
})
|
||||
|
||||
const body = JSON.stringify(payload)
|
||||
|
||||
if (options.useBeacon !== false && typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||
try {
|
||||
const blob = new Blob([body], { type: 'application/json' })
|
||||
const queued = navigator.sendBeacon(options.url, blob)
|
||||
|
||||
if (queued) {
|
||||
return Promise.resolve(true)
|
||||
}
|
||||
} catch {
|
||||
// Fall back to fetch.
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(options.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
keepalive: options.keepalive === true,
|
||||
body,
|
||||
}).then(() => true).catch(() => false)
|
||||
}
|
||||
|
||||
export function normalizeAcademySearchQuery(query = '') {
|
||||
const normalizedWhitespace = String(query).trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
return normalizedWhitespace.replace(/[^a-z0-9\s\-_]+/g, '').trim()
|
||||
}
|
||||
|
||||
export function trackAcademySearchResultClick(analytics, search, result) {
|
||||
if (!analytics?.eventUrl || !search?.query || !result?.contentType || !result?.contentId) {
|
||||
return
|
||||
}
|
||||
|
||||
void trackAcademyEvent('academy_search_result_click', result.contentType, result.contentId, {
|
||||
query: search.query,
|
||||
normalized_query: search.normalizedQuery || normalizeAcademySearchQuery(search.query),
|
||||
results_count: Number(search.resultsCount || 0),
|
||||
position: result.position || null,
|
||||
source: result.source || 'academy_search_results',
|
||||
filters: search.filters || {},
|
||||
}, {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
keepalive: true,
|
||||
})
|
||||
}
|
||||
|
||||
function contentViewEventType(contentType) {
|
||||
if (contentType === 'academy_lesson') return 'academy_lesson_view'
|
||||
if (contentType === 'academy_course') return 'academy_course_view'
|
||||
if (contentType === 'academy_prompt_pack') return 'academy_prompt_pack_view'
|
||||
if (contentType === 'academy_challenge') return 'academy_challenge_view'
|
||||
return 'academy_content_view'
|
||||
}
|
||||
|
||||
export function trackUpgradeClick(analytics, metadata = {}) {
|
||||
if (!analytics?.eventUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
void trackAcademyEvent('academy_upgrade_click', analytics?.contentType || 'academy_upgrade', analytics?.contentId || null, metadata, {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
useBeacon: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function useAcademyPageAnalytics(analytics) {
|
||||
useEffect(() => {
|
||||
if (!analytics?.enabled || !analytics?.eventUrl || typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const baseKey = `${analytics.pageName || window.location.pathname}:${analytics.contentType || 'page'}:${analytics.contentId || 'none'}`
|
||||
|
||||
void trackAcademyEvent('academy_page_view', analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
}, {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:page-view`,
|
||||
})
|
||||
|
||||
if (analytics.contentType || analytics.contentId) {
|
||||
void trackAcademyEvent(contentViewEventType(analytics.contentType), analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
}, {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:content-view`,
|
||||
})
|
||||
}
|
||||
|
||||
if (analytics.isPremium && analytics.isLocked) {
|
||||
void trackAcademyEvent('academy_premium_preview_view', analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
}, {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:premium-preview`,
|
||||
})
|
||||
}
|
||||
|
||||
const engagedTimer = window.setTimeout(() => {
|
||||
void trackAcademyEvent('academy_engaged_view', analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
engaged_seconds: 15,
|
||||
}, {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:engaged`,
|
||||
})
|
||||
}, 15000)
|
||||
|
||||
const sentMilestones = new Set()
|
||||
|
||||
const onScroll = () => {
|
||||
const doc = document.documentElement
|
||||
const scrollable = Math.max(1, doc.scrollHeight - window.innerHeight)
|
||||
const percent = Math.min(100, Math.round((window.scrollY / scrollable) * 100))
|
||||
|
||||
;[
|
||||
{ threshold: 50, eventType: 'academy_scroll_50' },
|
||||
{ threshold: 75, eventType: 'academy_scroll_75' },
|
||||
{ threshold: 100, eventType: 'academy_scroll_100' },
|
||||
].forEach((milestone) => {
|
||||
if (percent < milestone.threshold || sentMilestones.has(milestone.threshold)) {
|
||||
return
|
||||
}
|
||||
|
||||
sentMilestones.add(milestone.threshold)
|
||||
void trackAcademyEvent(milestone.eventType, analytics.contentType || null, analytics.contentId || null, {
|
||||
page_name: analytics.pageName,
|
||||
scroll_percent: milestone.threshold,
|
||||
}, {
|
||||
url: analytics.eventUrl,
|
||||
pageName: analytics.pageName,
|
||||
onceKey: `${baseKey}:scroll-${milestone.threshold}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(engagedTimer)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [analytics?.contentId, analytics?.contentType, analytics?.enabled, analytics?.eventUrl, analytics?.isLocked, analytics?.isPremium, analytics?.pageName])
|
||||
}
|
||||
70
resources/js/lib/academyAnalytics.test.js
Normal file
70
resources/js/lib/academyAnalytics.test.js
Normal file
@@ -0,0 +1,70 @@
|
||||
function prepareEnvironment() {
|
||||
document.head.innerHTML = '<meta name="csrf-token" content="csrf-token" />'
|
||||
vi.spyOn(Storage.prototype, 'getItem').mockReturnValue('visitor-123')
|
||||
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {})
|
||||
globalThis.fetch = vi.fn(() => Promise.resolve({ ok: true, headers: { get: () => 'application/json' }, json: () => Promise.resolve({ ok: true }) }))
|
||||
}
|
||||
|
||||
function cleanupEnvironment() {
|
||||
vi.restoreAllMocks()
|
||||
document.head.innerHTML = ''
|
||||
}
|
||||
|
||||
test('academy search click attribution uses sendBeacon without blocking navigation', async () => {
|
||||
prepareEnvironment()
|
||||
|
||||
const { trackAcademySearchResultClick } = await import('./academyAnalytics.js')
|
||||
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => true),
|
||||
})
|
||||
|
||||
const result = trackAcademySearchResultClick({
|
||||
eventUrl: '/academy/analytics/events',
|
||||
pageName: 'academy_prompts_index',
|
||||
}, {
|
||||
query: 'robot mascot',
|
||||
resultsCount: 12,
|
||||
filters: { difficulty: 'beginner' },
|
||||
}, {
|
||||
contentType: 'academy_prompt',
|
||||
contentId: 123,
|
||||
position: 3,
|
||||
})
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
expect(navigator.sendBeacon).toHaveBeenCalledTimes(1)
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled()
|
||||
|
||||
cleanupEnvironment()
|
||||
})
|
||||
|
||||
test('academy search click attribution falls back to keepalive fetch when sendBeacon cannot queue', async () => {
|
||||
prepareEnvironment()
|
||||
|
||||
const { trackAcademySearchResultClick } = await import('./academyAnalytics.js')
|
||||
|
||||
Object.defineProperty(navigator, 'sendBeacon', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => false),
|
||||
})
|
||||
|
||||
trackAcademySearchResultClick({
|
||||
eventUrl: '/academy/analytics/events',
|
||||
pageName: 'academy_prompts_index',
|
||||
}, {
|
||||
query: 'robot mascot',
|
||||
resultsCount: 12,
|
||||
filters: { difficulty: 'beginner' },
|
||||
}, {
|
||||
contentType: 'academy_prompt',
|
||||
contentId: 123,
|
||||
position: 3,
|
||||
})
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
|
||||
expect(globalThis.fetch.mock.calls[0][1].keepalive).toBe(true)
|
||||
|
||||
cleanupEnvironment()
|
||||
})
|
||||
@@ -75,21 +75,29 @@
|
||||
|
||||
return array_filter([
|
||||
'name' => $name,
|
||||
'url' => $username !== '' ? url('/@' . ltrim($username, '@')) : null,
|
||||
]);
|
||||
'url' => $username !== '' ? route('profile.show', ['username' => $username]) : null,
|
||||
], fn ($value) => $value !== null && $value !== '');
|
||||
};
|
||||
|
||||
// Ensure we always provide a top-level author object for structured data.
|
||||
$topAuthor = $makeForumAuthor($author ?? $opPost?->user ?? null);
|
||||
$topAuthor = $makeForumAuthor($author ?? $opPost?->user ?? $thread->user ?? null);
|
||||
if (! $topAuthor) {
|
||||
$topAuthor = ['name' => (string) ($opPost?->user?->name ?? $thread->user?->name ?? 'Skinbase')];
|
||||
$topAuthor = ['name' => (string) (config('app.name') ?: 'Skinbase')];
|
||||
}
|
||||
|
||||
$threadText = $threadDescription;
|
||||
if ($threadText === null || $threadText === '') {
|
||||
$threadBody = trim((string) strip_tags((string) ($thread->content ?? '')));
|
||||
$threadText = $threadBody !== ''
|
||||
? Str::limit($threadBody, 220)
|
||||
: Str::limit((string) $thread->title, 220);
|
||||
}
|
||||
|
||||
$forumMicrodata = [
|
||||
'kind' => 'topic',
|
||||
'canonical' => route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]),
|
||||
'title' => (string) $thread->title,
|
||||
'text' => $threadDescription,
|
||||
'text' => $threadText,
|
||||
'date_published' => $thread->created_at?->toIso8601String(),
|
||||
'date_modified' => ($thread->last_post_at ?? $thread->updated_at)?->toIso8601String(),
|
||||
'comment_count' => (int) ($reply_count ?? 0),
|
||||
@@ -119,7 +127,7 @@
|
||||
'text' => Str::limit($text, 300),
|
||||
'date_published' => $post->created_at?->toIso8601String(),
|
||||
'date_modified' => ($post->edited_at ?? $post->created_at)?->toIso8601String(),
|
||||
'author' => $makeForumAuthor($post->user ?? null) ?: ['name' => (string) ($post->user?->name ?? 'Skinbase')],
|
||||
'author' => $makeForumAuthor($post->user ?? null) ?: $topAuthor,
|
||||
], fn ($value) => $value !== null && $value !== '');
|
||||
})
|
||||
->values()
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
'thumbnailUrl' => $article->cover_mobile_url,
|
||||
'caption' => $article->title,
|
||||
'creditText' => $articleImageCreditText,
|
||||
'copyrightNotice' => $articleImageCreditText,
|
||||
'license' => $articleImageLicenseUrl,
|
||||
'acquireLicensePage' => $articleImageLicenseUrl,
|
||||
'creator' => [
|
||||
@@ -63,8 +64,14 @@
|
||||
'thumbnailUrl' => $article->cover_mobile_url,
|
||||
'caption' => $article->title,
|
||||
'creditText' => $articleImageCreditText,
|
||||
'copyrightNotice' => $articleImageCreditText,
|
||||
'license' => $articleImageLicenseUrl,
|
||||
'acquireLicensePage' => $articleImageLicenseUrl,
|
||||
'creator' => [
|
||||
'@type' => 'Organization',
|
||||
'name' => $articleImageCreditText,
|
||||
'url' => url('/'),
|
||||
],
|
||||
], fn (mixed $value): bool => $value !== null && $value !== '')
|
||||
: null,
|
||||
'datePublished' => $article->published_at?->toIso8601String(),
|
||||
@@ -73,6 +80,9 @@
|
||||
'author' => array_filter([
|
||||
'@type' => 'Person',
|
||||
'name' => $article->author?->name,
|
||||
'url' => $article->author?->username
|
||||
? route('profile.show', ['username' => $article->author->username])
|
||||
: null,
|
||||
]),
|
||||
'publisher' => [
|
||||
'@type' => 'Organization',
|
||||
|
||||
@@ -9,7 +9,13 @@
|
||||
@if(!empty($forumMicrodata['canonical']))<meta itemprop="mainEntityOfPage" content="{{ $forumMicrodata['canonical'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['canonical']))<meta itemprop="url" content="{{ $forumMicrodata['canonical'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['title']))<meta itemprop="headline" content="{{ $forumMicrodata['title'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['text']))<meta itemprop="text" content="{{ $forumMicrodata['text'] }}" />@endif
|
||||
@php
|
||||
$topicText = trim((string) ($forumMicrodata['text'] ?? ''));
|
||||
if ($topicText === '') {
|
||||
$topicText = trim((string) ($forumMicrodata['title'] ?? ''));
|
||||
}
|
||||
@endphp
|
||||
@if($topicText !== '')<meta itemprop="text" content="{{ $topicText }}" />@endif
|
||||
@if(!empty($forumMicrodata['date_published']))<meta itemprop="datePublished" content="{{ $forumMicrodata['date_published'] }}" />@endif
|
||||
@if(!empty($forumMicrodata['date_modified']))<meta itemprop="dateModified" content="{{ $forumMicrodata['date_modified'] }}" />@endif
|
||||
@if(isset($forumMicrodata['comment_count']))<meta itemprop="commentCount" content="{{ (int) $forumMicrodata['comment_count'] }}" />@endif
|
||||
@@ -74,20 +80,38 @@
|
||||
@if(!empty($forumMicrodata['list_name']))<meta itemprop="name" content="{{ $forumMicrodata['list_name'] }}" />@endif
|
||||
|
||||
@foreach(($forumMicrodata['items'] ?? []) as $index => $item)
|
||||
@php
|
||||
$itemType = $item['type'] ?? 'WebPage';
|
||||
$itemText = trim((string) ($item['text'] ?? ''));
|
||||
$itemAuthor = $item['author'] ?? null;
|
||||
|
||||
if ($itemType === 'DiscussionForumPosting') {
|
||||
if ($itemText === '') {
|
||||
$itemText = trim((string) ($item['description'] ?? $item['title'] ?? ''));
|
||||
}
|
||||
|
||||
if (empty($itemAuthor)) {
|
||||
$fallbackAuthorName = trim((string) ($item['uname'] ?? $item['author_name'] ?? ''));
|
||||
if ($fallbackAuthorName !== '') {
|
||||
$itemAuthor = ['name' => $fallbackAuthorName];
|
||||
}
|
||||
}
|
||||
}
|
||||
@endphp
|
||||
<div itemprop="itemListElement" itemscope itemtype="https://schema.org/ListItem">
|
||||
<meta itemprop="position" content="{{ $index + 1 }}" />
|
||||
<div itemprop="item" itemscope itemtype="https://schema.org/{{ $item['type'] ?? 'WebPage' }}">
|
||||
<div itemprop="item" itemscope itemtype="https://schema.org/{{ $itemType }}">
|
||||
@if(!empty($item['url']))<meta itemprop="url" content="{{ $item['url'] }}" />@endif
|
||||
@if(!empty($item['title']))<meta itemprop="{{ ($item['type'] ?? null) === 'DiscussionForumPosting' ? 'headline' : 'name' }}" content="{{ $item['title'] }}" />@endif
|
||||
@if(!empty($item['title']))<meta itemprop="{{ $itemType === 'DiscussionForumPosting' ? 'headline' : 'name' }}" content="{{ $item['title'] }}" />@endif
|
||||
@if(!empty($item['description']))<meta itemprop="description" content="{{ $item['description'] }}" />@endif
|
||||
@if(!empty($item['text']))<meta itemprop="text" content="{{ $item['text'] }}" />@endif
|
||||
@if(!empty($itemText))<meta itemprop="text" content="{{ $itemText }}" />@endif
|
||||
@if(!empty($item['date_published']))<meta itemprop="datePublished" content="{{ $item['date_published'] }}" />@endif
|
||||
@if(isset($item['comment_count']))<meta itemprop="commentCount" content="{{ (int) $item['comment_count'] }}" />@endif
|
||||
@if(!empty($item['date_modified']))<meta itemprop="dateModified" content="{{ $item['date_modified'] }}" />@endif
|
||||
@if(!empty($item['author']))
|
||||
@if(!empty($itemAuthor))
|
||||
<div itemprop="author" itemscope itemtype="https://schema.org/Person">
|
||||
@if(!empty($item['author']['url']))<meta itemprop="url" content="{{ $item['author']['url'] }}" />@endif
|
||||
@if(!empty($item['author']['name']))<meta itemprop="name" content="{{ $item['author']['name'] }}" />@endif
|
||||
@if(!empty($itemAuthor['url']))<meta itemprop="url" content="{{ $itemAuthor['url'] }}" />@endif
|
||||
@if(!empty($itemAuthor['name']))<meta itemprop="name" content="{{ $itemAuthor['name'] }}" />@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
55
resources/views/web-stories/index.blade.php
Normal file
55
resources/views/web-stories/index.blade.php
Normal file
@@ -0,0 +1,55 @@
|
||||
@extends('layouts.nova.content-layout')
|
||||
|
||||
@php
|
||||
$hero_title = 'Skinbase Web Stories';
|
||||
$hero_description = 'Explore visual stories from Skinbase Worlds, creator features, seasonal collections, and digital art highlights.';
|
||||
@endphp
|
||||
|
||||
@section('page-content')
|
||||
@if($stories->count() > 0)
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
@foreach($stories as $story)
|
||||
<a href="{{ route('web-stories.show', ['slug' => $story->slug]) }}" class="group overflow-hidden rounded-[28px] border border-white/10 bg-white/[0.03] transition hover:bg-white/[0.06]">
|
||||
<div class="aspect-[3/4] overflow-hidden bg-black/30">
|
||||
@if($story->posterPortraitUrl())
|
||||
<img src="{{ $story->posterPortraitUrl() }}" alt="{{ $story->title }}" class="h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]" loading="lazy">
|
||||
@else
|
||||
<div class="flex h-full items-center justify-center text-white/20">
|
||||
<i class="fa-solid fa-book-open-reader text-5xl"></i>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-300/80">Web Story</div>
|
||||
<h2 class="mt-3 line-clamp-2 text-xl font-semibold tracking-[-0.03em] text-white">{{ $story->title }}</h2>
|
||||
@if($story->excerpt)
|
||||
<p class="mt-3 line-clamp-3 text-sm leading-6 text-slate-300">{{ $story->excerpt }}</p>
|
||||
@endif
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2 text-xs uppercase tracking-[0.16em] text-slate-400">
|
||||
@if($story->world)
|
||||
<span class="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1">{{ $story->world->title }}</span>
|
||||
@endif
|
||||
@if($story->published_at)
|
||||
<time datetime="{{ $story->published_at->toIso8601String() }}">{{ $story->published_at->format('M j, Y') }}</time>
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-5 inline-flex items-center gap-2 text-sm font-semibold text-sky-300 transition group-hover:text-sky-200">
|
||||
View Story
|
||||
<i class="fa-solid fa-arrow-right text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-center">
|
||||
{{ $stories->links() }}
|
||||
</div>
|
||||
@else
|
||||
<div class="rounded-[28px] border border-white/10 bg-white/[0.03] px-8 py-14 text-center">
|
||||
<div class="text-white/20"><i class="fa-solid fa-book-open-reader text-5xl"></i></div>
|
||||
<h2 class="mt-4 text-xl font-semibold text-white">No Web Stories published yet</h2>
|
||||
<p class="mt-3 text-sm leading-6 text-slate-300">Published Skinbase Web Stories will appear here once they are ready.</p>
|
||||
</div>
|
||||
@endif
|
||||
@endsection
|
||||
156
resources/views/web-stories/show.blade.php
Normal file
156
resources/views/web-stories/show.blade.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html amp lang="{{ app()->getLocale() }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ $meta['title'] }}</title>
|
||||
<link rel="canonical" href="{{ $meta['canonical'] }}">
|
||||
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
|
||||
<meta name="description" content="{{ $meta['description'] }}">
|
||||
<meta name="robots" content="{{ $meta['robots'] }}">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:title" content="{{ $meta['og_title'] }}">
|
||||
<meta property="og:description" content="{{ $meta['og_description'] }}">
|
||||
<meta property="og:url" content="{{ $meta['og_url'] }}">
|
||||
<meta property="og:image" content="{{ $meta['og_image'] }}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{ $meta['twitter_title'] }}">
|
||||
<meta name="twitter:description" content="{{ $meta['twitter_description'] }}">
|
||||
<meta name="twitter:image" content="{{ $meta['twitter_image'] }}">
|
||||
<script async src="https://cdn.ampproject.org/v0.js"></script>
|
||||
<script async custom-element="amp-story" src="https://cdn.ampproject.org/v0/amp-story-1.0.js"></script>
|
||||
<script async custom-element="amp-video" src="https://cdn.ampproject.org/v0/amp-video-0.1.js"></script>
|
||||
<style amp-boilerplate>
|
||||
body {
|
||||
-webkit-animation: -amp-start 8s steps(1,end) 0s 1 normal both;
|
||||
-moz-animation: -amp-start 8s steps(1,end) 0s 1 normal both;
|
||||
-ms-animation: -amp-start 8s steps(1,end) 0s 1 normal both;
|
||||
animation: -amp-start 8s steps(1,end) 0s 1 normal both;
|
||||
}
|
||||
@-webkit-keyframes -amp-start { from { visibility: hidden; } to { visibility: visible; } }
|
||||
@-moz-keyframes -amp-start { from { visibility: hidden; } to { visibility: visible; } }
|
||||
@-ms-keyframes -amp-start { from { visibility: hidden; } to { visibility: visible; } }
|
||||
@-o-keyframes -amp-start { from { visibility: hidden; } to { visibility: visible; } }
|
||||
@keyframes -amp-start { from { visibility: hidden; } to { visibility: visible; } }
|
||||
</style>
|
||||
<noscript>
|
||||
<style amp-boilerplate>
|
||||
body {
|
||||
-webkit-animation: none;
|
||||
-moz-animation: none;
|
||||
-ms-animation: none;
|
||||
animation: none;
|
||||
}
|
||||
</style>
|
||||
</noscript>
|
||||
<style amp-custom>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: #020617;
|
||||
}
|
||||
.story-text {
|
||||
color: #ffffff;
|
||||
padding: 32px;
|
||||
text-shadow: 0 2px 18px rgba(0,0,0,.65);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
min-height: 100%;
|
||||
}
|
||||
.story-text--top {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.story-text--center {
|
||||
justify-content: center;
|
||||
}
|
||||
.story-kicker {
|
||||
font-size: 13px;
|
||||
letter-spacing: .16em;
|
||||
text-transform: uppercase;
|
||||
opacity: .85;
|
||||
}
|
||||
.story-title {
|
||||
font-size: 34px;
|
||||
line-height: 1.05;
|
||||
font-weight: 800;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.story-body {
|
||||
font-size: 17px;
|
||||
line-height: 1.35;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.story-cta {
|
||||
display: inline-block;
|
||||
margin-top: 18px;
|
||||
padding: 11px 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.92);
|
||||
color: #111827;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.overlay {
|
||||
background: linear-gradient(to top, rgba(0,0,0,.72), rgba(0,0,0,.12), rgba(0,0,0,.12));
|
||||
}
|
||||
.gradient-fill {
|
||||
background: linear-gradient(180deg, rgba(15,23,42,0.95) 0%, rgba(14,165,233,0.35) 100%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<amp-story
|
||||
standalone
|
||||
title="{{ $story->title }}"
|
||||
publisher="Skinbase"
|
||||
publisher-logo-src="{{ $story->publisherLogoUrl() }}"
|
||||
poster-portrait-src="{{ $story->posterPortraitUrl() }}"
|
||||
@if($story->posterSquareUrl()) poster-square-src="{{ $story->posterSquareUrl() }}" @endif
|
||||
>
|
||||
@foreach($story->orderedPages->where('active', true) as $page)
|
||||
<amp-story-page id="page-{{ $page->position }}">
|
||||
<amp-story-grid-layer template="fill">
|
||||
@if($page->background_type === \App\Models\WorldWebStoryPage::BACKGROUND_VIDEO && $page->backgroundUrl())
|
||||
<amp-video autoplay loop muted layout="fill" poster="{{ $page->desktopBackgroundUrl() ?: $page->backgroundUrl() }}">
|
||||
<source src="{{ $page->backgroundUrl() }}" type="video/mp4">
|
||||
</amp-video>
|
||||
@elseif($page->background_type === \App\Models\WorldWebStoryPage::BACKGROUND_GRADIENT || ! $page->backgroundUrl())
|
||||
<div class="gradient-fill"></div>
|
||||
@else
|
||||
<amp-img
|
||||
src="{{ $page->backgroundUrl() }}"
|
||||
width="1080"
|
||||
height="1920"
|
||||
layout="responsive"
|
||||
alt="{{ $page->alt_text ?: ($page->headline ?: $story->title) }}"
|
||||
></amp-img>
|
||||
@endif
|
||||
</amp-story-grid-layer>
|
||||
|
||||
<amp-story-grid-layer template="fill">
|
||||
<div class="overlay"></div>
|
||||
</amp-story-grid-layer>
|
||||
|
||||
<amp-story-grid-layer template="vertical" class="story-text story-text--{{ $page->text_position ?: 'bottom' }}">
|
||||
@if($page->caption)
|
||||
<div class="story-kicker">{{ $page->caption }}</div>
|
||||
@endif
|
||||
|
||||
@if($page->headline)
|
||||
<h1 class="story-title">{{ $page->headline }}</h1>
|
||||
@endif
|
||||
|
||||
@if($page->body)
|
||||
<p class="story-body">{{ $page->body }}</p>
|
||||
@endif
|
||||
|
||||
@if($page->cta_label && $page->cta_url)
|
||||
<a class="story-cta" href="{{ $page->cta_url }}">{{ $page->cta_label }}</a>
|
||||
@endif
|
||||
</amp-story-grid-layer>
|
||||
</amp-story-page>
|
||||
@endforeach
|
||||
</amp-story>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user