validate([ 'q' => ['nullable', 'string', 'max:120'], 'category' => ['nullable', 'string', 'max:140'], 'difficulty' => ['nullable', 'string', 'max:40'], 'tag' => ['nullable', 'string', 'max:60'], ]); $query = AcademyPromptTemplate::query() ->with('category') ->active() ->published() ->latest('published_at'); if (filled($filters['q'] ?? null)) { $query->where(function ($builder) use ($filters): void { $builder->where('title', 'like', '%' . $filters['q'] . '%') ->orWhere('excerpt', 'like', '%' . $filters['q'] . '%'); }); } if (filled($filters['category'] ?? null)) { $query->whereHas('category', fn ($builder) => $builder->where('slug', $filters['category'])); } if (filled($filters['difficulty'] ?? null)) { $query->where('difficulty', $filters['difficulty']); } if (filled($filters['tag'] ?? null)) { $tag = $filters['tag']; $query->whereJsonContains('tags', $tag); } $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', 'Browse AI prompt templates for wallpapers, worlds, editorial covers, robots, pixel art, and creator workflows.', route('academy.prompts.index', $request->query()), ) ->toArray(); return Inertia::render('Academy/List', [ 'pageType' => 'prompts', 'promptView' => 'library', 'title' => 'Prompt library', 'description' => 'Reusable prompt templates for wallpapers, worlds, mascots, covers, and digital art workflows.', 'seo' => $seo, 'breadcrumbs' => [ ['label' => 'Academy', 'href' => route('academy.index')], ['label' => 'Prompt Library', 'href' => route('academy.prompts.index')], ], 'items' => $prompts, 'filters' => $filters, 'categories' => $this->cache->categoriesByType('prompt'), 'pricingUrl' => route('academy.pricing'), 'coursesUrl' => route('academy.courses.index'), 'packsUrl' => route('academy.packs.index'), 'promptPopularUrl' => route('academy.prompts.popular'), 'promptLibraryUrl' => route('academy.prompts.index'), 'academyAccess' => array_merge($this->access->accessSummary($request->user()), [ 'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false) ? route('academy.billing.account') : route('academy.pricing'), ]), 'featuredPrompts' => $this->featuredPromptPayloads($request->user()), 'popularPrompts' => $this->popularPromptPayloads($request->user()), 'analytics' => [ 'enabled' => true, 'contentType' => AcademyAnalyticsContentType::PROMPT_LIBRARY, '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('academy'); } public function popular(Request $request): Response { abort_unless((bool) config('academy.enabled', true), 404); $validated = $request->validate([ 'period' => ['nullable', 'string', 'in:7d,30d,90d'], ]); $selectedPeriod = $this->selectedPopularPromptPeriod($validated['period'] ?? null); $from = now()->subDays($selectedPeriod['days'] - 1)->startOfDay(); $to = now()->endOfDay(); $rows = DB::query() ->fromSub( $this->popularity->queryBetween($from, $to) ->where('content_type', AcademyAnalyticsContentType::PROMPT) ->whereNotNull('content_id') ->selectRaw('content_id, sum(views) as views, sum(prompt_copies) as prompt_copies, sum(popularity_score) as popularity_score') ->groupBy('content_id'), 'prompt_rankings' ) ->orderByDesc('popularity_score') ->orderByDesc('prompt_copies') ->orderByDesc('views') ->paginate(12) ->withQueryString(); $prompts = AcademyPromptTemplate::query() ->with('category') ->active() ->published() ->whereIn('id', $rows->pluck('content_id')->map(static fn ($value): int => (int) $value)->all()) ->get() ->keyBy('id'); $baseRank = (($rows->currentPage() - 1) * $rows->perPage()); $rows->setCollection( $rows->getCollection() ->values() ->map(function (object $row, int $index) use ($prompts, $request, $baseRank, $selectedPeriod): ?array { $prompt = $prompts->get((int) $row->content_id); if (! $prompt instanceof AcademyPromptTemplate) { return null; } $payload = $this->access->promptPayload($prompt, $request->user()); $payload['ranking'] = [ 'rank' => $baseRank + $index + 1, 'views' => max(0, (int) ($row->views ?? 0)), 'prompt_copies' => max(0, (int) ($row->prompt_copies ?? 0)), 'popularity_score' => round((float) ($row->popularity_score ?? 0), 2), ]; $payload['spotlight'] = [ 'eyebrow' => max(0, (int) ($row->prompt_copies ?? 0)) > 0 ? sprintf('%d copies %s', (int) $row->prompt_copies, $selectedPeriod['eyebrow_suffix']) : sprintf('%d views %s', (int) $row->views, $selectedPeriod['eyebrow_suffix']), ]; return $payload; }) ->filter() ->values() ); $seo = app(SeoFactory::class) ->collectionListing( sprintf('%s Prompts — Skinbase Academy', $selectedPeriod['title_prefix']), sprintf('See which Skinbase Academy prompt templates are driving the most views and copies %s.', $selectedPeriod['description_suffix']), route('academy.prompts.popular', $request->query()), ) ->toArray(); return Inertia::render('Academy/List', [ 'pageType' => 'prompts', 'promptView' => 'popular', 'title' => sprintf('%s prompts', $selectedPeriod['title_prefix']), 'description' => sprintf('The prompt templates getting the most momentum from views and copies across the Academy %s.', $selectedPeriod['description_suffix']), 'seo' => $seo, 'breadcrumbs' => [ ['label' => 'Academy', 'href' => route('academy.index')], ['label' => 'Prompt Library', 'href' => route('academy.prompts.index')], ['label' => 'Popular Prompts', 'href' => route('academy.prompts.popular')], ], 'items' => $rows, 'filters' => [], 'categories' => [], 'pricingUrl' => route('academy.pricing'), 'coursesUrl' => route('academy.courses.index'), 'packsUrl' => route('academy.packs.index'), 'promptPopularUrl' => route('academy.prompts.popular'), 'promptLibraryUrl' => route('academy.prompts.index'), 'academyAccess' => array_merge($this->access->accessSummary($request->user()), [ 'billingUrl' => $request->user() && (bool) config('academy_billing.enabled', false) ? route('academy.billing.account') : route('academy.pricing'), ]), 'popularPeriod' => [ 'value' => $selectedPeriod['value'], 'label' => $selectedPeriod['label'], 'description' => $selectedPeriod['description'], ], 'popularPeriods' => collect($this->popularPromptPeriods()) ->map(fn (array $period): array => [ 'value' => $period['value'], 'label' => $period['label'], 'description' => $period['description'], 'href' => route('academy.prompts.popular', ['period' => $period['value']]), 'active' => $period['value'] === $selectedPeriod['value'], ]) ->values() ->all(), 'featuredPrompts' => $this->featuredPromptPayloads($request->user()), 'popularPrompts' => [], 'analytics' => [ 'enabled' => true, 'contentType' => AcademyAnalyticsContentType::PROMPT_POPULAR, 'contentId' => null, 'eventUrl' => route('academy.analytics.events.store'), 'pageName' => 'academy_prompts_popular', 'trackingKey' => sprintf('period:%s', $selectedPeriod['value']), 'metadata' => [ 'period' => $selectedPeriod['value'], 'period_days' => $selectedPeriod['days'], ], 'search' => null, 'isPremium' => false, 'isGuest' => $request->user() === null, 'isSubscriber' => $request->user()?->hasAcademyCreatorAccess() || $request->user()?->hasAcademyProAccess(), ], ])->rootView('academy'); } /** * @return array> */ private function popularPromptPeriods(): array { return [ [ 'value' => '7d', 'days' => 7, 'label' => '7 days', 'description' => 'Fresh momentum from the last 7 days.', 'title_prefix' => 'Top 7-day', 'description_suffix' => 'in the last 7 days', 'eyebrow_suffix' => 'in the last 7 days', ], [ 'value' => '30d', 'days' => 30, 'label' => '30 days', 'description' => 'The default monthly view of prompt momentum.', 'title_prefix' => 'Popular', 'description_suffix' => 'this month', 'eyebrow_suffix' => 'this month', ], [ 'value' => '90d', 'days' => 90, 'label' => '90 days', 'description' => 'Longer-running prompt momentum across the quarter.', 'title_prefix' => 'Top 90-day', 'description_suffix' => 'in the last 90 days', 'eyebrow_suffix' => 'in the last 90 days', ], ]; } /** * @return array */ private function selectedPopularPromptPeriod(?string $value): array { return collect($this->popularPromptPeriods()) ->first(fn (array $period): bool => $period['value'] === $value) ?? $this->popularPromptPeriods()[1]; } public function show(Request $request, string $slug): Response { abort_unless((bool) config('academy.enabled', true), 404); $prompt = AcademyPromptTemplate::query() ->with('category') ->active() ->published() ->where('slug', $slug) ->firstOrFail(); $payload = $this->access->promptPayload($prompt, $request->user(), true); $canonical = route('academy.prompts.show', ['slug' => $prompt->slug]); $description = Str::limit(trim((string) ($prompt->seo_description ?? $prompt->excerpt ?? 'Skinbase Academy prompt template.')), 160, '...'); $seo = app(SeoFactory::class)->collectionPage( (string) ($prompt->seo_title ?? ($prompt->title . ' — Skinbase Academy')), $description, $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' => $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('academy'); } /** * @param array $payload * @return array */ 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 !== []); } /** * @return array> */ private function featuredPromptPayloads(mixed $viewer, int $limit = 4): array { return collect($this->cache->featuredPrompts()) ->take($limit) ->map(function (AcademyPromptTemplate $prompt) use ($viewer): array { $payload = $this->access->promptPayload($prompt, $viewer); $payload['spotlight'] = [ 'eyebrow' => $prompt->prompt_of_week ? 'Prompt of the week' : 'Featured pick', ]; return $payload; }) ->values() ->all(); } /** * @return array> */ private function popularPromptPayloads(mixed $viewer, int $limit = 4): array { $rows = $this->popularity->queryBetween(now()->subDays(29)->startOfDay(), now()->endOfDay()) ->where('content_type', AcademyAnalyticsContentType::PROMPT) ->whereNotNull('content_id') ->selectRaw('content_id, sum(views) as views, sum(prompt_copies) as prompt_copies, sum(popularity_score) as popularity_score') ->groupBy('content_id') ->orderByDesc('popularity_score') ->limit($limit) ->get(); if ($rows->isEmpty()) { return []; } $prompts = AcademyPromptTemplate::query() ->with('category') ->active() ->published() ->whereIn('id', $rows->pluck('content_id')->all()) ->get() ->keyBy('id'); return $rows->map(function ($row) use ($prompts, $viewer): ?array { $prompt = $prompts->get((int) $row->content_id); if (! $prompt instanceof AcademyPromptTemplate) { return null; } $payload = $this->access->promptPayload($prompt, $viewer); $copies = max(0, (int) ($row->prompt_copies ?? 0)); $views = max(0, (int) ($row->views ?? 0)); $payload['spotlight'] = [ 'eyebrow' => $copies > 0 ? sprintf('%d copies this month', $copies) : sprintf('%d views this month', $views), ]; return $payload; }) ->filter() ->values() ->all(); } }