Implement academy analytics, billing, and web stories updates

This commit is contained in:
2026-05-26 07:27:29 +02:00
parent 456c3d6bb0
commit 0b33a1b074
177 changed files with 27360 additions and 2685 deletions

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Services\Academy\AcademyAnalyticsContentResolver;
use App\Services\Academy\AcademyAnalyticsService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\AcademyAnalytics\AcademyAnalyticsEventType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
final class AcademyAnalyticsEventController extends Controller
{
public function __construct(
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyAnalyticsContentResolver $resolver,
) {
}
public function store(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($request->expectsJson() || $request->isJson(), 422);
$validated = $request->validate([
'event_type' => ['required', 'string', Rule::in(AcademyAnalyticsEventType::values())],
'content_type' => ['nullable', 'string', Rule::in(AcademyAnalyticsContentType::values())],
'content_id' => ['nullable', 'integer', 'min:1'],
'metadata' => ['nullable', 'array'],
'visitor_id' => ['nullable', 'string', 'max:120'],
'session_id' => ['nullable', 'string', 'max:120'],
'url' => ['nullable', 'string', 'max:4000'],
'route_name' => ['nullable', 'string', 'max:255'],
'referrer' => ['nullable', 'string', 'max:4000'],
'utm_source' => ['nullable', 'string', 'max:255'],
'utm_medium' => ['nullable', 'string', 'max:255'],
'utm_campaign' => ['nullable', 'string', 'max:255'],
]);
if (isset($validated['metadata']) && strlen((string) json_encode($validated['metadata'])) > 8192) {
return response()->json([
'message' => 'Metadata payload is too large.',
], 422);
}
$contentType = $validated['content_type'] ?? null;
$contentId = $validated['content_id'] ?? null;
if ($contentType !== null && AcademyAnalyticsContentType::requiresContentId($contentType) && $contentId === null) {
return response()->json([
'message' => 'content_id is required for this content type.',
], 422);
}
if ($contentType !== null && $contentId !== null && ! $this->resolver->exists($contentType, (int) $contentId)) {
return response()->json([
'message' => 'Unknown Academy analytics content target.',
], 422);
}
if (($validated['event_type'] ?? null) === AcademyAnalyticsEventType::SEARCH_RESULT_CLICK) {
validator([
'content_type' => $contentType,
'content_id' => $contentId,
'metadata' => $validated['metadata'] ?? [],
], [
'content_type' => ['required', 'string', Rule::in([
AcademyAnalyticsContentType::PROMPT,
AcademyAnalyticsContentType::LESSON,
AcademyAnalyticsContentType::COURSE,
AcademyAnalyticsContentType::PROMPT_PACK,
AcademyAnalyticsContentType::CHALLENGE,
])],
'content_id' => ['required', 'integer', 'min:1'],
'metadata.query' => ['required', 'string', 'max:120'],
'metadata.normalized_query' => ['required', 'string', 'max:120'],
'metadata.results_count' => ['required', 'integer', 'min:0'],
'metadata.position' => ['nullable', 'integer', 'min:1'],
'metadata.source' => ['nullable', 'string', 'max:120'],
'metadata.filters' => ['nullable', 'array'],
])->validate();
}
$this->analytics->track($validated, $request->user(), $request);
return response()->json([
'ok' => true,
]);
}
}

View File

@@ -0,0 +1,422 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyBillingPlanService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Laravel\Cashier\Checkout;
use Laravel\Cashier\Subscription;
final class AcademyBillingController extends Controller
{
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyBillingPlanService $plans,
) {}
public function pricing(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
$this->plans->assertConfigured();
/** @var User|null $user */
$user = $request->user();
$canonical = \route('academy.pricing');
$seo = \app(SeoFactory::class)
->collectionPage(
'Skinbase AI Academy Pricing — Skinbase',
'Compare Skinbase AI Academy Creator and Pro tiers, start free, and manage premium access through Stripe billing.',
$canonical,
)
->toArray();
$seo['og_type'] = 'website';
$activePlan = $user instanceof User ? $this->activePlan($user) : null;
return \Inertia\Inertia::render('Academy/Billing/Pricing', [
'seo' => $seo,
'billingEnabled' => $this->plans->enabled(),
'currentTier' => $this->access->currentTier($user),
'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false,
'activePlanKey' => $activePlan['key'] ?? null,
'activePlanLabel' => $activePlan['label'] ?? null,
'catalog' => $this->catalog(),
'links' => [
'login' => \route('login'),
'pricing' => \route('academy.pricing'),
'billingAccount' => $user ? \route('academy.billing.account') : null,
'checkout' => $user ? \route('academy.billing.checkout') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::UPGRADE,
'contentId' => null,
'eventUrl' => \route('academy.analytics.events.store'),
'pageName' => 'academy_billing_pricing',
'isPremium' => false,
'isGuest' => $user === null,
'isSubscriber' => $user?->hasAcademyCreatorAccess() || $user?->hasAcademyProAccess(),
],
])->rootView('collections');
}
public function checkout(\Illuminate\Http\Request $request): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
\abort_unless((bool) \config('academy.enabled', true), 404);
if (! $this->plans->enabled()) {
return $this->billingDisabledResponse($request, 'Academy billing is not enabled yet.');
}
$this->plans->assertConfigured();
$user = $request->user();
if (! $user instanceof User) {
return \redirect()->route('login');
}
if ($user->email_verified_at === null) {
throw \Illuminate\Validation\ValidationException::withMessages([
'plan' => 'Verify your email address before starting Academy billing.',
]);
}
$validated = $request->validate([
'plan' => ['required', 'string'],
]);
$plan = $this->plans->plan((string) $validated['plan']);
if ($plan === null) {
throw \Illuminate\Validation\ValidationException::withMessages([
'plan' => 'Select a valid Academy billing plan.',
]);
}
if (! ($plan['configured'] ?? false)) {
return $this->missingPriceIdResponse($request, (string) $plan['key']);
}
if (! ($plan['price_id_valid'] ?? false)) {
return $this->invalidPriceIdResponse($request, (string) $plan['key']);
}
if ($this->access->hasActiveAcademySubscription($user)) {
return \redirect()->route('academy.billing.portal');
}
try {
return $user
->newSubscription($this->plans->subscriptionName(), (string) $plan['stripe_price_id'])
->withMetadata([
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
])
->checkout([
'success_url' => \route('academy.billing.success').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => \route('academy.billing.cancel'),
'allow_promotion_codes' => true,
'metadata' => [
'skinbase_module' => 'academy',
'user_id' => (string) $user->id,
'academy_plan' => (string) $plan['key'],
'academy_tier' => (string) $plan['tier'],
],
]);
} catch (\Throwable $exception) {
\report($exception);
return $this->checkoutErrorResponse($request, $exception);
}
}
public function checkoutLegacy(\Illuminate\Http\Request $request, string $plan): Checkout|\Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$request->merge([
'plan' => $this->plans->normalizePlanKey($plan),
]);
return $this->checkout($request);
}
public function portal(\Illuminate\Http\Request $request): \Illuminate\Http\RedirectResponse
{
\abort_unless((bool) \config('academy.enabled', true), 404);
\abort_unless($this->plans->enabled(), 404);
/** @var User|null $user */
$user = $request->user();
if (! $user instanceof User || \blank($user->stripe_id)) {
return \redirect()->route('academy.billing.account')->with('error', 'No Stripe billing profile is connected to this account yet.');
}
return $user->redirectToBillingPortal(\route('academy.billing.account'));
}
public function success(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
/** @var User|null $user */
$user = $request->user();
$currentTier = $this->access->currentTier($user);
return \Inertia\Inertia::render('Academy/Billing/Success', [
'message' => 'Payment is being confirmed. Your access will update automatically.',
'currentTier' => $currentTier,
'isSubscribed' => $user instanceof User ? $this->access->hasActiveAcademySubscription($user) : false,
'links' => [
'pricing' => \route('academy.pricing'),
'account' => $user ? \route('academy.billing.account') : null,
'academy' => \route('academy.index'),
],
'sessionId' => $request->query('session_id'),
])->rootView('collections');
}
public function cancel(): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
return \Inertia\Inertia::render('Academy/Billing/Cancel', [
'message' => 'Checkout was canceled. No payment was made.',
'links' => [
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
],
])->rootView('collections');
}
public function account(\Illuminate\Http\Request $request): \Inertia\Response
{
\abort_unless((bool) \config('academy.enabled', true), 404);
\abort_unless($this->plans->enabled(), 404);
/** @var User $user */
$user = $request->user();
$subscription = $this->academySubscription($user);
$activePlan = $this->activePlan($user);
return \Inertia\Inertia::render('Academy/Billing/Account', [
'currentTier' => $this->access->currentTier($user),
'isSubscribed' => $this->access->hasActiveAcademySubscription($user),
'activePlan' => $activePlan ? [
'key' => $activePlan['key'],
'label' => $activePlan['label'],
'price_display' => $activePlan['price_display'] ?? null,
'tier' => $activePlan['tier'],
] : null,
'subscription' => $subscription ? [
'name' => $subscription->type,
'status' => $subscription->stripe_status,
'active' => $subscription->active(),
'onGracePeriod' => $subscription->onGracePeriod(),
'endsAt' => $subscription->ends_at?->toISOString(),
'priceIds' => $subscription->items->pluck('stripe_price')->filter()->values()->all(),
] : null,
'links' => [
'portal' => \route('academy.billing.portal'),
'pricing' => \route('academy.pricing'),
'academy' => \route('academy.index'),
],
])->rootView('collections');
}
/**
* @return array<int, array<string, mixed>>
*/
private function catalog(): array
{
$definitions = [
'creator' => [
'name' => 'Creator',
'description' => 'Entry premium access for prompt systems, creator lessons, and saved Academy workflows.',
'badge' => 'Paid',
'featured' => false,
'features' => [
'Creator lessons and walkthroughs',
'Full Creator prompt templates',
'Prompt save and reuse flows',
'Upgrade path into Pro later',
],
],
'pro' => [
'name' => 'Pro',
'description' => 'Full Academy access across Creator and Pro lessons, prompts, and future premium drops.',
'badge' => 'Recommended',
'featured' => true,
'features' => [
'Everything in Creator',
'Advanced Pro lessons and prompt systems',
'Priority access to future Academy premium features',
'Stripe billing portal for upgrades and invoices',
],
],
];
return collect($definitions)
->map(function (array $definition, string $tier): array {
$plan = $this->plans->plan($tier.'_monthly');
$plans = $plan !== null ? [[
'key' => $plan['key'],
'label' => $plan['label'],
'interval' => $plan['interval'],
'amount' => $plan['amount'],
'currency' => $plan['currency'],
'price_display' => $plan['price_display'],
'configured' => $plan['configured'],
'price_id_valid' => $plan['price_id_valid'],
]] : [];
return [
'tier' => $tier,
'name' => $definition['name'],
'description' => $definition['description'],
'badge' => $definition['badge'],
'featured' => $definition['featured'],
'features' => $definition['features'],
'plans' => $plans,
];
})
->values()
->all();
}
private function academySubscription(User $user): ?Subscription
{
$subscription = $user->subscription($this->plans->subscriptionName());
return $subscription instanceof Subscription
? $subscription->loadMissing('items')
: null;
}
/**
* @return array<string, mixed>|null
*/
private function activePlan(User $user): ?array
{
$subscription = $this->academySubscription($user);
if (! $subscription instanceof Subscription || (! $subscription->active() && ! $subscription->onGracePeriod())) {
return null;
}
$matchedPlan = null;
foreach ($subscription->items as $item) {
$priceId = trim((string) $item->stripe_price);
if ($priceId === '') {
continue;
}
$plan = $this->plans->planForPriceId($priceId);
if ($plan === null) {
continue;
}
if ($matchedPlan === null || $this->planRank((string) $plan['tier']) > $this->planRank((string) $matchedPlan['tier'])) {
$matchedPlan = $plan;
}
}
if ($matchedPlan !== null) {
return $matchedPlan;
}
$fallbackPriceId = trim((string) $subscription->stripe_price);
return $fallbackPriceId !== '' ? $this->plans->planForPriceId($fallbackPriceId) : null;
}
private function planRank(string $tier): int
{
return match (strtolower(trim($tier))) {
'admin' => 40,
'pro' => 30,
'creator' => 20,
default => 10,
};
}
private function billingDisabledResponse(\Illuminate\Http\Request $request, string $message): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$payload = [
'ok' => false,
'code' => 'academy_payments_disabled',
'message' => $message,
];
if ($request->expectsJson()) {
return \response()->json($payload, 423);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function missingPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'The selected Academy plan is not configured yet. Please try again later.';
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_price_missing',
'message' => $message,
'plan' => $planKey,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function invalidPriceIdResponse(\Illuminate\Http\Request $request, string $planKey): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'The selected Academy plan is misconfigured. Please contact support before continuing.';
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_price_invalid',
'message' => $message,
'plan' => $planKey,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
private function checkoutErrorResponse(\Illuminate\Http\Request $request, \Throwable $exception): \Illuminate\Http\JsonResponse|\Illuminate\Http\RedirectResponse
{
$message = 'Academy checkout could not be started right now.';
if (app()->hasDebugModeEnabled() && trim($exception->getMessage()) !== '') {
$message .= ' '.$exception->getMessage();
}
if ($request->expectsJson()) {
return \response()->json([
'ok' => false,
'code' => 'academy_billing_checkout_failed',
'message' => $message,
], 422);
}
return \redirect()->route('academy.pricing')->with('error', $message);
}
}

View File

@@ -7,6 +7,8 @@ namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyChallenge;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -15,7 +17,10 @@ use Inertia\Response;
final class AcademyChallengeController extends Controller
{
public function __construct(private readonly AcademyAccessService $access)
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyInteractionService $interactions,
)
{
}
@@ -49,6 +54,16 @@ final class AcademyChallengeController extends Controller
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_challenges_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
@@ -86,12 +101,31 @@ final class AcademyChallengeController extends Controller
$challenge->cover_image,
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::CHALLENGE, (int) $challenge->id);
return Inertia::render('Academy/Show', [
'pageType' => 'challenge',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'submitUrl' => $request->user() ? route('academy.challenges.submit', ['slug' => $challenge->slug]) : null,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::CHALLENGE,
'contentId' => (int) $challenge->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_challenge_show',
'isPremium' => (string) ($challenge->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
}
}

View File

@@ -8,9 +8,11 @@ use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyCourseLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyCourseNavigationService;
use App\Services\Academy\AcademyCourseProgressService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -23,6 +25,7 @@ final class AcademyCourseController extends Controller
private readonly AcademyCacheService $cache,
private readonly AcademyCourseNavigationService $navigation,
private readonly AcademyCourseProgressService $progress,
private readonly AcademyInteractionService $interactions,
) {
}
@@ -82,6 +85,16 @@ final class AcademyCourseController extends Controller
'featuredCourses' => $featuredCourses->all(),
'filters' => $filters,
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_courses_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
@@ -172,6 +185,8 @@ final class AcademyCourseController extends Controller
)
->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::COURSE, (int) $course->id);
return Inertia::render('Academy/CoursesShow', [
'seo' => $seo,
'course' => $coursePayload,
@@ -179,6 +194,23 @@ final class AcademyCourseController extends Controller
'unsectionedLessons' => $unsectionedLessons,
'pricingUrl' => route('academy.pricing'),
'startUrl' => $request->user() ? route('academy.courses.start', ['course' => $course->slug]) : null,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::COURSE,
'contentId' => (int) $course->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_course_show',
'isPremium' => (string) ($course->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => false,
],
])->rootView('collections');
}
}

View File

@@ -6,14 +6,17 @@ namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Services\Academy\AcademyProgressService;
use App\Services\Academy\AcademyCourseProgressService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
final class AcademyCourseEnrollmentController extends Controller
{
public function __construct(private readonly AcademyCourseProgressService $progress)
{
public function __construct(
private readonly AcademyCourseProgressService $progress,
private readonly AcademyProgressService $academyProgress,
) {
}
public function start(Request $request, AcademyCourse $course): RedirectResponse
@@ -21,7 +24,7 @@ final class AcademyCourseEnrollmentController extends Controller
abort_unless((bool) config('academy.enabled', true), 404);
abort_unless($course->isPublished(), 404);
$this->progress->markEnrollmentStarted($request->user(), $course);
$this->academyProgress->startCourse($request->user(), (int) $course->id, $request);
$continueLesson = $this->progress->getContinueLesson($request->user(), $course);
if ($continueLesson?->lesson) {

View File

@@ -8,8 +8,10 @@ use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Services\Academy\AcademyCourseNavigationService;
use App\Services\Academy\AcademyCourseProgressService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Support\Str;
use Illuminate\Http\Request;
@@ -22,6 +24,7 @@ final class AcademyCourseLessonController extends Controller
private readonly AcademyAccessService $access,
private readonly AcademyCourseNavigationService $navigation,
private readonly AcademyCourseProgressService $progress,
private readonly AcademyInteractionService $interactions,
) {
}
@@ -68,6 +71,8 @@ final class AcademyCourseLessonController extends Controller
(string) $course->title,
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id);
return Inertia::render('Academy/Show', [
'pageType' => 'lesson',
'item' => $payload,
@@ -79,6 +84,26 @@ final class AcademyCourseLessonController extends Controller
'pricingUrl' => route('academy.pricing'),
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'progressRoutes' => [
'startLesson' => $request->user() ? route('academy.progress.lesson.start') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::LESSON,
'contentId' => (int) $lesson->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_course_lesson_show',
'isPremium' => (string) ($payload['access_level'] ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
'courseContext' => [
'id' => (int) $course->id,
'title' => (string) $course->title,

View File

@@ -11,6 +11,7 @@ use App\Models\AcademyLesson;
use App\Models\AcademyPromptTemplate;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyCacheService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
@@ -81,6 +82,16 @@ final class AcademyHomeController extends Controller
'featuredLessons' => collect($home['featuredLessons'])->map(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()))->values()->all(),
'featuredPrompts' => collect($home['featuredPrompts'])->map(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()))->values()->all(),
'featuredChallenges' => collect($home['featuredChallenges'])->map(fn (AcademyChallenge $challenge): array => $this->access->challengePayload($challenge, $request->user(), true))->values()->all(),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::HOME,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_home',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use InvalidArgumentException;
final class AcademyInteractionController extends Controller
{
public function __construct(private readonly AcademyInteractionService $interactions)
{
}
public function like(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $this->validatePayload($request);
try {
$payload = $this->interactions->toggleLike($request->user(), (string) $validated['content_type'], (int) $validated['content_id'], $request);
} catch (InvalidArgumentException $exception) {
return response()->json(['message' => $exception->getMessage()], 422);
}
return response()->json($payload);
}
public function save(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $this->validatePayload($request);
try {
$payload = $this->interactions->toggleSave($request->user(), (string) $validated['content_type'], (int) $validated['content_id'], $request);
} catch (InvalidArgumentException $exception) {
return response()->json(['message' => $exception->getMessage()], 422);
}
return response()->json($payload);
}
/**
* @return array<string, mixed>
*/
private function validatePayload(Request $request): array
{
return $request->validate([
'content_type' => ['required', 'string', Rule::in([
AcademyAnalyticsContentType::PROMPT,
AcademyAnalyticsContentType::LESSON,
AcademyAnalyticsContentType::COURSE,
AcademyAnalyticsContentType::PROMPT_PACK,
AcademyAnalyticsContentType::CHALLENGE,
])],
'content_id' => ['required', 'integer', 'min:1'],
]);
}
}

View File

@@ -8,7 +8,10 @@ use App\Http\Controllers\Controller;
use App\Models\AcademyCourse;
use App\Models\AcademyLesson;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyAnalyticsService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -20,6 +23,8 @@ final class AcademyLessonController extends Controller
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions,
) {}
public function index(Request $request): Response
@@ -56,6 +61,10 @@ final class AcademyLessonController extends Controller
$lessons = $query->paginate(12)->withQueryString();
$lessons->getCollection()->transform(fn (AcademyLesson $lesson): array => $this->access->lessonPayload($lesson, $request->user()));
if (filled($filters['q'] ?? null)) {
$this->analytics->trackSearch((string) $filters['q'], (int) $lessons->total(), array_filter($filters), $request);
}
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Lessons — Skinbase',
@@ -73,6 +82,20 @@ final class AcademyLessonController extends Controller
'filters' => $filters,
'categories' => $this->cache->categoriesByType('lesson'),
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_lessons_index',
'search' => filled($filters['q'] ?? null) ? [
'query' => (string) $filters['q'],
'resultsCount' => (int) $lessons->total(),
] : null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
@@ -148,6 +171,8 @@ final class AcademyLessonController extends Controller
(string) ($lesson->series_name ?: $lesson->category?->name ?: 'Academy'),
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::LESSON, (int) $lesson->id);
return Inertia::render('Academy/Show', [
'pageType' => 'lesson',
'item' => $payload,
@@ -159,6 +184,26 @@ final class AcademyLessonController extends Controller
'pricingUrl' => route('academy.pricing'),
'completeUrl' => $request->user() ? route('academy.lessons.complete', ['lesson' => $lesson->id]) : null,
'completed' => $request->user()?->academyLessonProgress()->where('lesson_id', $lesson->id)->whereNotNull('completed_at')->exists() ?? false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'progressRoutes' => [
'startLesson' => $request->user() ? route('academy.progress.lesson.start') : null,
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::LESSON,
'contentId' => (int) $lesson->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_lesson_show',
'isPremium' => (string) ($lesson->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
}
}

View File

@@ -5,13 +5,15 @@ declare(strict_types=1);
namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
final class AcademyPricingController extends Controller
{
public function index(): Response
public function index(Request $request): Response
{
abort_unless((bool) config('academy.enabled', true), 404);
@@ -67,6 +69,16 @@ final class AcademyPricingController extends Controller
],
],
],
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::UPGRADE,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_pricing',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
}

View File

@@ -20,6 +20,32 @@ final class AcademyProgressController extends Controller
) {
}
public function startLesson(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'lesson_id' => ['required', 'integer', 'min:1'],
'course_id' => ['nullable', 'integer', 'min:1'],
]);
$lesson = AcademyLesson::query()->findOrFail((int) $validated['lesson_id']);
abort_unless($this->access->canAccessLesson($request->user(), $lesson), 403);
$courseId = $request->filled('course_id') ? (int) $validated['course_id'] : null;
if ($courseId !== null) {
$course = AcademyCourse::query()->published()->findOrFail($courseId);
abort_unless($course->courseLessons()->where('lesson_id', $lesson->id)->exists(), 403);
}
$record = $this->progress->startLesson($request->user(), (int) $lesson->id, $courseId, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
]);
}
public function complete(Request $request, AcademyLesson $lesson): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
@@ -31,7 +57,7 @@ final class AcademyProgressController extends Controller
$course = AcademyCourse::query()->published()->find($request->integer('course_id'));
}
$record = $this->progress->markLessonComplete($request->user(), $lesson, $course);
$record = $this->progress->markLessonComplete($request->user(), $lesson, $course, $request);
return response()->json([
'ok' => true,
@@ -39,4 +65,55 @@ final class AcademyProgressController extends Controller
'completed_at' => $record->completed_at?->toISOString(),
]);
}
public function completeLesson(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'lesson_id' => ['required', 'integer', 'min:1'],
'course_id' => ['nullable', 'integer', 'min:1'],
]);
$lesson = AcademyLesson::query()->findOrFail((int) $validated['lesson_id']);
return $this->complete($request, $lesson);
}
public function startCourse(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'course_id' => ['required', 'integer', 'min:1'],
]);
$course = AcademyCourse::query()->published()->findOrFail((int) $validated['course_id']);
$record = $this->progress->startCourse($request->user(), (int) $course->id, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
'progress_percent' => (int) $record->progress_percent,
]);
}
public function completeCourse(Request $request): JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
$validated = $request->validate([
'course_id' => ['required', 'integer', 'min:1'],
]);
$course = AcademyCourse::query()->published()->findOrFail((int) $validated['course_id']);
$record = $this->progress->completeCourse($request->user(), (int) $course->id, $request);
return response()->json([
'ok' => true,
'status' => (string) $record->status,
'progress_percent' => (int) $record->progress_percent,
'completed' => (string) $record->status === 'completed',
]);
}
}

View File

@@ -7,9 +7,13 @@ namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyPromptTemplate;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyAnalyticsService;
use App\Services\Academy\AcademyCacheService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
@@ -19,10 +23,12 @@ final class AcademyPromptController extends Controller
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyCacheService $cache,
private readonly AcademyAnalyticsService $analytics,
private readonly AcademyInteractionService $interactions,
) {
}
public function index(Request $request): Response
public function index(Request $request): Response|JsonResponse
{
abort_unless((bool) config('academy.enabled', true), 404);
@@ -62,6 +68,14 @@ final class AcademyPromptController extends Controller
$prompts = $query->paginate(12)->withQueryString();
$prompts->getCollection()->transform(fn (AcademyPromptTemplate $prompt): array => $this->access->promptPayload($prompt, $request->user()));
if (filled($filters['q'] ?? null)) {
$this->analytics->trackSearch((string) $filters['q'], (int) $prompts->total(), array_filter($filters), $request);
}
if ($request->expectsJson()) {
return response()->json($prompts);
}
$seo = app(SeoFactory::class)
->collectionListing(
'Academy Prompts — Skinbase',
@@ -79,6 +93,20 @@ final class AcademyPromptController extends Controller
'filters' => $filters,
'categories' => $this->cache->categoriesByType('prompt'),
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => filled($filters['q'] ?? null) ? AcademyAnalyticsContentType::SEARCH : null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompts_index',
'search' => filled($filters['q'] ?? null) ? [
'query' => (string) $filters['q'],
'resultsCount' => (int) $prompts->total(),
] : null,
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
@@ -102,15 +130,75 @@ final class AcademyPromptController extends Controller
$canonical,
$payload['preview_image'] ?? null,
)->toArray();
$existingSchemas = $seo['json_ld'] ?? [];
if (! is_array($existingSchemas) || ! array_is_list($existingSchemas)) {
$existingSchemas = [$existingSchemas];
}
$seo['json_ld'] = [
...$existingSchemas,
$this->promptStructuredData($payload, $canonical, $description),
];
$canSavePrompt = $request->user() !== null && $this->access->canAccessPrompt($request->user(), $prompt);
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT, (int) $prompt->id);
return Inertia::render('Academy/Show', [
'pageType' => 'prompt',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'saveUrl' => $request->user() ? route('academy.prompts.save', ['prompt' => $prompt->id]) : null,
'unsaveUrl' => $request->user() ? route('academy.prompts.unsave', ['prompt' => $prompt->id]) : null,
'saved' => $request->user()?->academySavedPrompts()->where('prompt_template_id', $prompt->id)->exists() ?? false,
'saveUrl' => $canSavePrompt ? route('academy.prompts.save', ['prompt' => $prompt->id]) : null,
'unsaveUrl' => $canSavePrompt ? route('academy.prompts.unsave', ['prompt' => $prompt->id]) : null,
'saved' => $canSavePrompt ? ($request->user()?->academySavedPrompts()->where('prompt_template_id', $prompt->id)->exists() ?? false) : false,
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT,
'contentId' => (int) $prompt->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_prompt_show',
'isPremium' => (string) ($prompt->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
private function promptStructuredData(array $payload, string $canonical, string $description): array
{
$imageUrls = array_values(array_unique(array_filter([
$payload['preview_image'] ?? null,
...collect((array) ($payload['public_examples'] ?? []))
->map(fn (array $example): ?string => $example['image_url'] ?? $example['thumb_url'] ?? null)
->filter()
->values()
->all(),
], fn (mixed $value): bool => is_string($value) && $value !== '')));
$isFree = (string) ($payload['access_level'] ?? 'free') === 'free';
return array_filter([
'@context' => 'https://schema.org',
'@type' => ['CreativeWork', 'LearningResource'],
'name' => (string) ($payload['title'] ?? 'Skinbase Academy prompt'),
'description' => $description,
'url' => $canonical,
'image' => $imageUrls !== [] ? $imageUrls : null,
'isAccessibleForFree' => $isFree,
'hasPart' => $isFree ? null : [
'@type' => 'WebPageElement',
'isAccessibleForFree' => false,
'cssSelector' => '.academy-paywalled-content',
],
], fn (mixed $value): bool => $value !== null && $value !== '' && $value !== []);
}
}

View File

@@ -7,6 +7,8 @@ namespace App\Http\Controllers\Academy;
use App\Http\Controllers\Controller;
use App\Models\AcademyPromptPack;
use App\Services\Academy\AcademyAccessService;
use App\Services\Academy\AcademyInteractionService;
use App\Support\AcademyAnalytics\AcademyAnalyticsContentType;
use App\Support\Seo\SeoFactory;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -15,7 +17,10 @@ use Inertia\Response;
final class AcademyPromptPackController extends Controller
{
public function __construct(private readonly AcademyAccessService $access)
public function __construct(
private readonly AcademyAccessService $access,
private readonly AcademyInteractionService $interactions,
)
{
}
@@ -50,6 +55,16 @@ final class AcademyPromptPackController extends Controller
'filters' => [],
'categories' => [],
'pricingUrl' => route('academy.pricing'),
'analytics' => [
'enabled' => true,
'contentType' => null,
'contentId' => null,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_packs_index',
'isPremium' => false,
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
],
])->rootView('collections');
}
@@ -72,11 +87,30 @@ final class AcademyPromptPackController extends Controller
$pack->cover_image,
)->toArray();
$interaction = $this->interactions->getInteractionState($request->user(), AcademyAnalyticsContentType::PROMPT_PACK, (int) $pack->id);
return Inertia::render('Academy/Show', [
'pageType' => 'pack',
'item' => $payload,
'seo' => $seo,
'pricingUrl' => route('academy.pricing'),
'interaction' => $interaction,
'interactionRoutes' => [
'like' => route('academy.interactions.like'),
'save' => route('academy.interactions.save'),
],
'loginUrl' => route('login'),
'analytics' => [
'enabled' => true,
'contentType' => AcademyAnalyticsContentType::PROMPT_PACK,
'contentId' => (int) $pack->id,
'eventUrl' => route('academy.analytics.events.store'),
'pageName' => 'academy_pack_show',
'isPremium' => (string) ($pack->access_level ?? 'free') !== 'free',
'isGuest' => $request->user() === null,
'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(),
'isLocked' => (bool) ($payload['locked'] ?? false),
],
])->rootView('collections');
}
}