Optimize academy

This commit is contained in:
2026-06-09 13:16:01 +02:00
parent f89ee937c0
commit 5af95f6533
109 changed files with 6862 additions and 719 deletions

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { Head, Link } from '@inertiajs/react'
import React, { useState, useRef, useEffect } from 'react'
import { Head, Link, useForm, usePage } from '@inertiajs/react'
import AccessBadge from '../../../components/academy/billing/AccessBadge'
function formatDate(iso) {
@@ -12,6 +12,72 @@ function formatDate(iso) {
}
export default function AcademyBillingAccount({ currentTier, isSubscribed, subscription, activePlan = null, links = {} }) {
const { flash, auth } = usePage().props
const { data, setData, post, processing } = useForm({
issue_type: 'billing',
contact_email: auth?.user?.email || '',
message: '',
session_id: null,
})
function IssueTypeDropdown({ value, onChange }) {
const options = [
{ value: 'billing', label: 'Billing question' },
{ value: 'payment', label: 'Payment problem' },
{ value: 'upgrade', label: 'Upgrade problem' },
{ value: 'downgrade', label: 'Downgrade problem' },
{ value: 'cancel', label: 'Cancellation problem' },
{ value: 'access', label: 'Access not updated' },
{ value: 'other', label: 'Other' },
]
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
function onDoc(e) {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
const current = options.find((o) => o.value === value) || options[0]
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((s) => !s)}
className="w-full text-left rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 flex items-center justify-between"
>
<span>{current.label}</span>
<svg className="ml-2 h-4 w-4 text-amber-100/70" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 8l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
</button>
{open ? (
<div className="absolute left-0 top-full mt-2 w-full rounded-xl border border-white/10 bg-[#10192e] shadow-2xl z-50 overflow-hidden">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value)
setOpen(false)
}}
className={`w-full text-left px-4 py-3 text-sm ${opt.value === value ? 'bg-white/[0.03] text-white' : 'text-slate-300 hover:bg-white/[0.02]'}`}
>
{opt.label}
</button>
))}
</div>
) : null}
</div>
)
}
function getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
const endsAt = formatDate(subscription?.endsAt)
const onGracePeriod = subscription?.onGracePeriod === true
const subscriptionActive = subscription?.active === true
@@ -21,6 +87,16 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<Head title="Academy Subscription" />
<div className="mx-auto max-w-[1280px] space-y-8">
{flash?.error ? (
<section className="rounded-[20px] border border-rose-300/20 bg-rose-300/8 p-4">
<p className="font-semibold text-rose-100">{flash.error}</p>
</section>
) : null}
{flash?.success ? (
<section className="rounded-[20px] border border-emerald-300/20 bg-emerald-300/8 p-4">
<p className="font-semibold text-emerald-100">{flash.success}</p>
</section>
) : null}
{/* 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">
@@ -42,12 +118,12 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<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
<a
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>
</a>
</section>
) : null}
@@ -123,20 +199,75 @@ export default function AcademyBillingAccount({ currentTier, isSubscribed, subsc
<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.
Use the subscription portal to cancel or manage billing details. Plan upgrades are handled here on Skinbase.
</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>
{/* Use a plain anchor to perform a full navigation to Stripe (avoid Inertia XHR/CORS) */}
<a
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"
>
Open billing portal
</a>
<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>
{/* Quick upgrade form: allow Creator -> Pro upgrade in one click (full POST, not Inertia) */}
{activePlan?.tier === 'creator' ? (
<form action={links.checkout} method="POST" data-no-inertia className="mt-2">
<input type="hidden" name="_token" value={getCsrfToken()} />
<input type="hidden" name="plan" value="pro_monthly" />
<button type="submit" className="inline-flex w-full items-center justify-center rounded-full border border-emerald-300/25 bg-emerald-300/10 px-5 py-3 text-sm font-semibold text-emerald-100 transition hover:bg-emerald-300/18">Upgrade to Pro now</button>
</form>
) : null}
{links.reportIssue ? (
<div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-300/8 p-4">
<p className="text-sm font-semibold text-amber-100">Need help with billing or access?</p>
<p className="mt-1 text-xs leading-5 text-amber-100/80">
Send a quick report here if payment, access, or subscription changes do not behave as expected.
</p>
<form
onSubmit={(event) => {
event.preventDefault()
post(links.reportIssue, { preserveScroll: true })
}}
className="mt-3 space-y-3"
>
<div className="grid gap-3">
<label className="space-y-1 relative">
<span className="text-xs font-medium text-amber-100/80">Issue type</span>
{/* Custom dropdown to avoid native browser option styling */}
<IssueTypeDropdown value={data.issue_type} onChange={(v) => setData('issue_type', v)} />
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-amber-100/80">Reply email</span>
<input
type="email"
value={data.contact_email}
onChange={(event) => setData('contact_email', event.target.value)}
placeholder="you@example.com"
className="w-full rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 placeholder:text-amber-100/40"
/>
</label>
</div>
<textarea
value={data.message}
onChange={(event) => setData('message', event.target.value)}
placeholder="Describe the issue you hit, what you expected, and anything already charged or missing"
className="min-h-[96px] w-full rounded-xl border border-amber-300/20 bg-black/20 p-3 text-sm text-amber-50 placeholder:text-amber-100/40"
/>
<button
type="submit"
disabled={processing}
className="inline-flex w-full items-center justify-center rounded-full border border-amber-300/30 bg-amber-300/12 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-300/18 disabled:cursor-not-allowed disabled:opacity-60"
>
{processing ? 'Sending report...' : 'Send support report'}
</button>
</form>
</div>
) : null}
<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]"

View File

@@ -83,7 +83,7 @@ function SidePanel({ currentTier, isSubscribed, activePlanLabel, activePlanPrice
)
}
export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics }) {
export default function AcademyBillingPricing({ seo, billingEnabled, currentTier, isSubscribed, activePlanKey = null, activePlanLabel = null, catalog = [], links = {}, analytics, missingRemote = [] }) {
const { auth, errors, flash } = usePage().props
useAcademyPageAnalytics(analytics)
@@ -151,6 +151,12 @@ export default function AcademyBillingPricing({ seo, billingEnabled, currentTier
{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}
{Array.isArray(missingRemote) && missingRemote.length > 0 ? (
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-300/8 px-4 py-3 text-sm font-medium text-amber-50">
<p className="font-semibold">Purchases temporarily disabled:</p>
<p className="mt-1 text-xs">The following plans could not be verified in Stripe: {missingRemote.join(', ')}</p>
</div>
) : null}
</div>
<SidePanel
@@ -217,4 +223,4 @@ export default function AcademyBillingPricing({ seo, billingEnabled, currentTier
</div>
</main>
)
}
}

View File

@@ -1,8 +1,12 @@
import React from 'react'
import { Head, Link } from '@inertiajs/react'
import { Head, Link, usePage, useForm } from '@inertiajs/react'
import AccessBadge from '../../../components/academy/billing/AccessBadge'
export default function AcademyBillingSuccess({ currentTier, isSubscribed, links = {} }) {
const { auth } = usePage().props
const sessionId = usePage().props.sessionId || null
const userEmail = auth?.user?.email ?? null
const { data, setData, post, processing } = useForm({ message: '', session_id: sessionId })
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" />
@@ -21,6 +25,25 @@ export default function AcademyBillingSuccess({ currentTier, isSubscribed, links
? '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>
{!isSubscribed ? (
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-300/8 px-4 py-3 text-sm text-amber-50">
<p className="font-semibold">If your access isn't updated automatically</p>
<p className="mt-1">If your Academy access doesn't appear within a few minutes, email <strong>academy@skinbase.org</strong> or click the button below to open a prefilled message. Include your account email{userEmail ? ` (${userEmail})` : ''} and the checkout session id{sessionId ? `: ${sessionId}` : '.'}</p>
<div className="mt-3 flex flex-wrap items-start gap-4">
<form onSubmit={(e) => { e.preventDefault(); post(links.reportIssue, { preserveScroll: true }) }} className="flex w-full max-w-lg items-start gap-2">
<textarea value={data.message} onChange={(e) => setData('message', e.target.value)} placeholder="Optional: Tell us what you expected to see or any useful details" className="flex-1 rounded-md bg-black/20 border border-amber-300/20 p-2 text-sm text-amber-50" rows={3} />
<button type="submit" disabled={processing} className="rounded-full border border-amber-300/30 bg-amber-300/12 px-4 py-2 text-sm font-semibold text-amber-900 hover:bg-amber-300/16">Send report</button>
</form>
<div className="text-xs text-amber-100">
<div>- Wait 23 minutes and refresh the Academy page.</div>
<div>- If you still lack access, use the form above or email academy@skinbase.org.</div>
</div>
</div>
</div>
) : null}
</section>
<div className="flex flex-wrap gap-3">

View File

@@ -595,6 +595,192 @@ function PromptPlaceholderCard({ placeholder }) {
)
}
function PromptFilledExampleCard({ example, analytics, contentId, index }) {
const placeholderEntries = Object.entries(example?.placeholder_values || {}).filter(([key, value]) => String(key || '').trim() && value != null && value !== '' && value !== false)
return (
<article className="rounded-[28px] border border-white/10 bg-black/20 p-5 shadow-[0_18px_42px_rgba(2,6,23,0.16)]">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-violet-200/75">Filled example {index + 1}</p>
<h3 className="mt-2 text-xl font-semibold tracking-[-0.03em] text-white">{example?.title || `Example ${index + 1}`}</h3>
{example?.description ? <p className="mt-3 text-sm leading-7 text-slate-300">{example.description}</p> : null}
</div>
{example?.prompt ? (
<PromptCopyButton
prompt={example.prompt}
label="Copy example"
analytics={analytics}
contentId={contentId}
eventType="academy_prompt_filled_example_copy"
metadata={{ copy_type: 'filled_example', filled_example_index: index, source: 'prompt_filled_examples' }}
/>
) : null}
</div>
{placeholderEntries.length ? (
<div className="mt-5 flex flex-wrap gap-2">
{placeholderEntries.map(([key, value]) => (
<span key={key} className="rounded-full border border-violet-300/20 bg-violet-300/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.16em] text-violet-100">
{key}: <span className="normal-case tracking-normal text-white">{String(value)}</span>
</span>
))}
</div>
) : null}
{example?.prompt ? <pre className="mt-5 whitespace-pre-wrap rounded-[24px] border border-white/10 bg-slate-950/80 p-4 text-sm leading-7 text-slate-100">{example.prompt}</pre> : null}
{example?.negative_prompt ? (
<div className="mt-4 rounded-[24px] border border-white/10 bg-slate-950/60 p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-400">Negative prompt</p>
<PromptCopyButton
prompt={example.negative_prompt}
label="Copy negative"
analytics={analytics}
contentId={contentId}
eventType="academy_prompt_filled_example_negative_copy"
metadata={{ copy_type: 'filled_example_negative', filled_example_index: index, source: 'prompt_filled_examples' }}
/>
</div>
<pre className="mt-3 whitespace-pre-wrap text-sm leading-7 text-slate-200">{example.negative_prompt}</pre>
</div>
) : null}
</article>
)
}
function PromptFilledExamplesSection({ examples, analytics, contentId }) {
const visibleExamples = Array.isArray(examples) ? examples.filter((example) => example && typeof example === 'object') : []
const [activeExampleIndex, setActiveExampleIndex] = useState(0)
const examplesScrollRef = useRef(null)
const [canScrollExamplesLeft, setCanScrollExamplesLeft] = useState(false)
const [canScrollExamplesRight, setCanScrollExamplesRight] = useState(false)
useEffect(() => {
if (typeof window === 'undefined') {
return undefined
}
const updateExampleScrollState = () => {
const element = examplesScrollRef.current
if (!element) {
setCanScrollExamplesLeft(false)
setCanScrollExamplesRight(false)
return
}
const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth)
setCanScrollExamplesLeft(element.scrollLeft > 6)
setCanScrollExamplesRight(element.scrollLeft < maxScrollLeft - 6)
}
updateExampleScrollState()
const element = examplesScrollRef.current
if (!element) {
return undefined
}
element.addEventListener('scroll', updateExampleScrollState, { passive: true })
window.addEventListener('resize', updateExampleScrollState, { passive: true })
return () => {
element.removeEventListener('scroll', updateExampleScrollState)
window.removeEventListener('resize', updateExampleScrollState)
}
}, [visibleExamples.length])
useEffect(() => {
if (!visibleExamples.length) {
setActiveExampleIndex(0)
return
}
setActiveExampleIndex((current) => Math.max(0, Math.min(current, visibleExamples.length - 1)))
}, [visibleExamples.length])
if (!visibleExamples.length) return null
const activeExample = visibleExamples[activeExampleIndex] || visibleExamples[0]
const activeExampleLabel = String(activeExample?.title || '').trim() || `Example ${activeExampleIndex + 1}`
const activeExampleDescription = String(activeExample?.description || '').trim()
const scrollExamples = (direction) => {
const element = examplesScrollRef.current
if (!element) return
const amount = Math.max(220, Math.floor(element.clientWidth * 0.65))
element.scrollBy({
left: direction === 'left' ? -amount : amount,
behavior: 'smooth',
})
}
return (
<div className="mt-6 space-y-5">
<div className="rounded-[24px] border border-violet-300/15 bg-violet-300/10 p-5 md:p-6">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Selected example</p>
<h3 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-[2rem]">{activeExampleLabel}</h3>
{activeExampleDescription ? <p className="mt-3 text-sm leading-7 text-slate-200 md:text-base">{activeExampleDescription}</p> : null}
</div>
<div className="relative">
<div className={`pointer-events-none absolute inset-y-0 left-0 z-10 w-14 bg-gradient-to-r from-[#211c3a] via-[#211c3a]/85 to-transparent transition ${canScrollExamplesLeft ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
<div className={`pointer-events-none absolute inset-y-0 right-0 z-10 w-14 bg-gradient-to-l from-[#211c3a] via-[#211c3a]/85 to-transparent transition ${canScrollExamplesRight ? 'opacity-100' : 'opacity-0'}`} aria-hidden="true" />
<button
type="button"
aria-label="Scroll filled examples left"
onClick={() => scrollExamples('left')}
className={`absolute left-2 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-slate-950/80 text-white/80 shadow-[0_16px_36px_rgba(2,6,23,0.28)] backdrop-blur transition ${canScrollExamplesLeft ? 'opacity-100 hover:scale-105 hover:bg-slate-900/95' : 'pointer-events-none opacity-0'}`}
>
<i className="fa-solid fa-chevron-left text-sm" />
</button>
<button
type="button"
aria-label="Scroll filled examples right"
onClick={() => scrollExamples('right')}
className={`absolute right-2 top-1/2 z-20 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/12 bg-slate-950/80 text-white/80 shadow-[0_16px_36px_rgba(2,6,23,0.28)] backdrop-blur transition ${canScrollExamplesRight ? 'opacity-100 hover:scale-105 hover:bg-slate-900/95' : 'pointer-events-none opacity-0'}`}
>
<i className="fa-solid fa-chevron-right text-sm" />
</button>
<div ref={examplesScrollRef} className="overflow-x-auto pb-1 scrollbar-none [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div className="inline-flex min-w-full gap-2.5 px-1 py-1">
{visibleExamples.map((example, index) => {
const isActive = index === activeExampleIndex
const exampleLabel = String(example?.title || '').trim() || `Example ${index + 1}`
return (
<button
key={`${example.title || 'filled-example-tab'}-${index}`}
type="button"
onClick={() => setActiveExampleIndex(index)}
aria-pressed={isActive}
title={exampleLabel}
className={`max-w-full whitespace-nowrap rounded-full border px-4 py-2.5 text-sm font-semibold uppercase tracking-[0.18em] transition ${isActive ? 'border-violet-300/30 bg-violet-300/18 text-white shadow-[0_12px_30px_rgba(76,29,149,0.24)]' : 'border-white/10 bg-white/[0.04] text-violet-100/80 hover:border-violet-300/20 hover:bg-violet-300/10 hover:text-white'}`}
>
<span className="block max-w-[240px] truncate">{exampleLabel}</span>
</button>
)
})}
</div>
</div>
</div>
<PromptFilledExampleCard
key={`${activeExample.title || 'filled-example-active'}-${activeExampleIndex}`}
example={activeExample}
analytics={analytics}
contentId={contentId}
index={activeExampleIndex}
/>
</div>
)
}
function PromptHelperPromptCard({ helperPrompt, analytics, contentId }) {
if (!helperPrompt || typeof helperPrompt !== 'object') return null
@@ -1088,11 +1274,25 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
|| promptDocumentation.data_accuracy_notes.length,
)
const hasPromptPlaceholders = Boolean(item?.has_placeholder_inputs) && promptPlaceholders.length > 0
const promptFilledExamples = Array.isArray(item?.filled_examples)
? item.filled_examples.filter((example) => example && typeof example === 'object' && [
example.title,
example.description,
example.prompt,
example.negative_prompt,
...(example.placeholder_values && typeof example.placeholder_values === 'object' ? Object.values(example.placeholder_values) : []),
].some((value) => value != null && value !== '' && value !== false))
: []
const hasPromptFilledExamples = promptFilledExamples.length > 0
const promptFilledExamplesTotal = Number(item?.filled_examples_total || promptFilledExamples.length || 0)
const promptHasMoreFilledExamples = Boolean(item?.has_more_filled_examples) || promptFilledExamplesTotal > promptFilledExamples.length
const promptHasFullFilledExamplesAccess = Boolean(item?.has_full_filled_examples_access)
const promptHasLockedFilledExamples = Boolean(item?.has_filled_examples) && (!Boolean(item?.can_access_filled_examples) || promptHasMoreFilledExamples)
const promptHasLockedHelperPrompts = Boolean(item?.has_helper_prompts) && !promptHasFullAccess
const promptHasLockedVariants = Boolean(item?.has_prompt_variants) && !promptHasFullAccess
const hasPromptHelperPrompts = promptHelperPrompts.length > 0
const hasPromptVariants = promptVariants.length > 0
const showPromptHelperPrompts = false
const showPromptHelperPrompts = true
const promptAccessRequirement = item?.access_requirement || promptRequirementText(item?.access_level)
const promptUnlockTitle = item?.unlock_heading || promptUnlockHeading(item?.access_level)
const promptUnlockDetails = item?.unlock_description || promptUnlockDescription(item?.access_level)
@@ -2103,6 +2303,45 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
</section>
) : null}
{hasPromptFilledExamples ? (
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(167,139,250,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Filled examples</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">
{promptFilledExamplesTotal > 0 ? `${promptFilledExamplesTotal} ready-made prompt runs for real user inputs` : 'Ready-made prompt runs for real user inputs'}
</h2>
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">
{promptHasMoreFilledExamples
? `You can view ${promptFilledExamples.length} example${promptFilledExamples.length === 1 ? '' : 's'} right now. Upgrade to Pro to unlock all ${promptFilledExamplesTotal} filled prompt runs and copy a closer starting point instead of filling everything from scratch.`
: 'These examples show how the prompt looks after swapping real placeholder values, so you can copy a closer starting point instead of filling everything from scratch.'}
</p>
</div>
<PromptFilledExamplesSection examples={promptFilledExamples} analytics={analytics} contentId={item.id} />
</section>
) : null}
{promptHasLockedFilledExamples ? (
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(167,139,250,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="max-w-3xl">
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-violet-100/80">Filled examples</p>
<h2 className="mt-2 text-2xl font-semibold tracking-[-0.04em] text-white md:text-3xl">
{promptHasMoreFilledExamples && hasPromptFilledExamples
? `${Math.max(promptFilledExamplesTotal - promptFilledExamples.length, 0)} more filled prompt example${promptFilledExamplesTotal - promptFilledExamples.length === 1 ? '' : 's'} are available`
: `${promptFilledExamplesTotal || 5} filled prompt examples are included`}
</h2>
<p className="mt-3 text-sm leading-7 text-slate-300 md:text-base">
{promptHasMoreFilledExamples && hasPromptFilledExamples
? 'Creator access includes a smaller set here. Upgrade to Academy Pro to unlock the remaining filled prompt runs.'
: 'This prompt ships with ready-made filled examples for different user inputs, but they unlock only for Academy Pro members.'}
</p>
</div>
<div className="mt-6">
<LockedPanel pricingUrl={pricingUrl} label="prompt" accessLevel="pro" onUpgrade={() => trackUpgradeClick(analytics, { source: 'prompt_filled_examples_locked_panel' })} />
</div>
</section>
) : null}
{showPromptHelperPrompts && hasPromptHelperPrompts ? (
<section className="academy-paywalled-content rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(255,183,139,0.12),transparent_30%),linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 text-slate-200 shadow-[0_24px_80px_rgba(2,6,23,0.28)] md:p-8">
<div className="max-w-3xl">
@@ -2230,4 +2469,4 @@ export default function AcademyShow({ pageType, item, relatedLessons = [], relat
<ImageLightbox gallery={lightboxGallery} onClose={() => setLightboxGallery(null)} onNavigate={navigateLightboxGallery} />
</main>
)
}
}