Implement academy analytics, billing, and web stories updates
This commit is contained in:
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
Reference in New Issue
Block a user