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