Implement academy analytics, billing, and web stories updates
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
422
app/Http/Controllers/Academy/AcademyBillingController.php
Normal file
422
app/Http/Controllers/Academy/AcademyBillingController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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 !== []);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user