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', 'title' => 'Prompt library', 'description' => 'Reusable prompt templates for wallpapers, worlds, mascots, covers, and digital art workflows.', 'seo' => $seo, 'items' => $prompts, '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'); } 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('collections'); } /** * @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 !== []); } }