*/ private array $assetExistsCache = []; /** * @var array */ private array $paidTierCache = []; /** * @var array */ private array $subscriptionCache = []; /** * @var array|null */ private ?array $priceTierMap = null; public function canAccess(?User $user, string $requiredLevel): bool { return $this->canAccessContent($user, $requiredLevel); } public function canAccessContent(?User $user, string $accessLevel): bool { $accessLevel = $this->normalizeAccessLevel($accessLevel); if ($accessLevel === 'free') { return true; } if ($user === null) { return false; } return $this->rankForLevel($this->currentTier($user)) >= $this->rankForLevel($accessLevel); } public function currentTier(?User $user): string { if (! $user instanceof User) { return 'free'; } if ($this->isAcademyAdmin($user)) { return 'admin'; } return $this->paidTier($user) ?? 'free'; } public function paidTier(?User $user): ?string { if (! $user instanceof User) { return null; } $cacheKey = (int) $user->getKey(); if (array_key_exists($cacheKey, $this->paidTierCache)) { return $this->paidTierCache[$cacheKey]; } return $this->paidTierCache[$cacheKey] = $this->resolveSubscriptionTier($user) ?? $this->resolveLegacyPaidTier($user); } public function hasActiveAcademySubscription(User $user): bool { return $this->activeAcademySubscription($user) instanceof Subscription; } /** * @return array */ public function accessSummary(?User $user): array { if (! $user instanceof User) { return [ 'signedIn' => false, 'tier' => 'free', 'tierLabel' => 'Guest', 'hasPaidAccess' => false, 'status' => 'guest', 'statusLabel' => 'Preview access only', 'expiresAt' => null, 'dateLabel' => null, 'renewsAutomatically' => false, 'source' => 'none', ]; } if ($this->isAcademyAdmin($user)) { return [ 'signedIn' => true, 'tier' => 'admin', 'tierLabel' => 'Admin', 'hasPaidAccess' => true, 'status' => 'staff_access', 'statusLabel' => 'Full staff access', 'expiresAt' => null, 'dateLabel' => null, 'renewsAutomatically' => false, 'source' => 'admin', ]; } $tier = $this->currentTier($user); $subscription = $this->activeAcademySubscription($user); if ($subscription instanceof Subscription) { $trialEndsAt = $subscription->trial_ends_at?->toISOString(); $endsAt = $subscription->ends_at?->toISOString(); if ($subscription->onGracePeriod()) { return [ 'signedIn' => true, 'tier' => $tier, 'tierLabel' => $this->tierLabel($tier), 'hasPaidAccess' => $tier !== 'free', 'status' => 'grace_period', 'statusLabel' => 'Cancels soon', 'expiresAt' => $endsAt, 'dateLabel' => 'Access ends', 'renewsAutomatically' => false, 'source' => 'subscription', ]; } if ($subscription->onTrial()) { return [ 'signedIn' => true, 'tier' => $tier, 'tierLabel' => $this->tierLabel($tier), 'hasPaidAccess' => $tier !== 'free', 'status' => 'trialing', 'statusLabel' => 'Trial active', 'expiresAt' => $trialEndsAt, 'dateLabel' => 'Trial ends', 'renewsAutomatically' => ! $subscription->cancelled(), 'source' => 'subscription', ]; } return [ 'signedIn' => true, 'tier' => $tier, 'tierLabel' => $this->tierLabel($tier), 'hasPaidAccess' => $tier !== 'free', 'status' => 'active', 'statusLabel' => 'Renews automatically', 'expiresAt' => null, 'dateLabel' => null, 'renewsAutomatically' => true, 'source' => 'subscription', ]; } if ($tier !== 'free') { return [ 'signedIn' => true, 'tier' => $tier, 'tierLabel' => $this->tierLabel($tier), 'hasPaidAccess' => true, 'status' => 'active', 'statusLabel' => 'Full access active', 'expiresAt' => null, 'dateLabel' => null, 'renewsAutomatically' => false, 'source' => 'legacy_role', ]; } return [ 'signedIn' => true, 'tier' => 'free', 'tierLabel' => 'Free', 'hasPaidAccess' => false, 'status' => 'free', 'statusLabel' => 'Free access', 'expiresAt' => null, 'dateLabel' => null, 'renewsAutomatically' => false, 'source' => 'none', ]; } public function canAccessLesson(?User $user, AcademyLesson $lesson): bool { return $this->canAccessContent($user, (string) $lesson->access_level); } public function canAccessPrompt(?User $user, AcademyPromptTemplate $prompt): bool { return $this->canAccessContent($user, (string) $prompt->access_level); } public function canAccessPack(?User $user, AcademyPromptPack $pack): bool { return $this->canAccessContent($user, (string) $pack->access_level); } public function canAccessChallenge(?User $user, AcademyChallenge $challenge): bool { return $this->canAccessContent($user, (string) $challenge->access_level); } public function canAccessCourseLesson(?User $user, AcademyCourseLesson $courseLesson): bool { $accessLevel = trim((string) ($courseLesson->access_override ?: $courseLesson->lesson?->access_level ?: 'free')); return $this->canAccessContent($user, $accessLevel); } public function lessonPayload(AcademyLesson $lesson, ?User $viewer, bool $includeFull = false, ?bool $authorizedOverride = null): array { $authorized = $authorizedOverride ?? $this->canAccessLesson($viewer, $lesson); $fullContent = $this->injectHeadingIds((string) ($lesson->content ?? '')); return [ 'id' => (int) $lesson->id, 'title' => (string) $lesson->title, 'slug' => (string) $lesson->slug, 'lesson_number' => $lesson->lesson_number, 'formatted_lesson_number' => $lesson->formatted_lesson_number, 'course_order' => $lesson->course_order, 'series_name' => (string) ($lesson->series_name ?? ''), 'lesson_label' => $lesson->lesson_label, 'excerpt' => (string) ($lesson->excerpt ?? ''), 'content' => ($authorized && $includeFull) ? $fullContent : null, 'content_preview' => $authorized ? null : $this->previewText($fullContent, 360), 'difficulty' => (string) $lesson->difficulty, 'access_level' => (string) $lesson->access_level, 'lesson_type' => (string) $lesson->lesson_type, 'cover_image' => $lesson->cover_image, 'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?? '')), 'article_cover_image' => $lesson->article_cover_image, 'article_cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->article_cover_image ?? '')), 'tags' => array_values((array) ($lesson->tags ?? [])), 'video_url' => $authorized ? $lesson->video_url : null, 'reading_minutes' => (int) $lesson->reading_minutes, 'featured' => (bool) $lesson->featured, 'active' => (bool) $lesson->active, 'published_at' => $lesson->published_at?->toISOString(), 'category' => $lesson->category ? [ 'id' => (int) $lesson->category->id, 'name' => (string) $lesson->category->name, 'slug' => (string) $lesson->category->slug, ] : null, 'blocks' => ($authorized && $includeFull) ? $lesson->activeBlocks->map(fn (AcademyLessonBlock $block): ?array => $this->lessonBlockPayload($block))->filter()->values()->all() : [], 'locked' => ! $authorized, 'can_access' => $authorized, ]; } public function coursePayload(AcademyCourse $course, ?User $viewer, array $options = []): array { $progress = is_array($options['progress'] ?? null) ? $options['progress'] : null; $lessonCount = (int) ($course->lessons_count_cache ?: $course->courseLessons()->count()); return [ 'id' => (int) $course->id, 'title' => (string) $course->title, 'slug' => (string) $course->slug, 'subtitle' => (string) ($course->subtitle ?? ''), 'excerpt' => (string) ($course->excerpt ?? ''), 'description' => (string) ($course->description ?? ''), 'cover_image' => (string) ($course->cover_image ?? ''), 'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($course->cover_image ?? '')), 'teaser_image' => (string) ($course->teaser_image ?? ''), 'teaser_image_url' => $this->resolveLessonCoverImageUrl((string) ($course->teaser_image ?? '')), 'access_level' => (string) $course->access_level, 'difficulty' => (string) $course->difficulty, 'status' => (string) $course->status, 'is_featured' => (bool) $course->is_featured, 'order_num' => (int) ($course->order_num ?? 0), 'estimated_minutes' => (int) ($course->estimated_minutes ?? 0), 'lessons_count' => $lessonCount, 'published_at' => $course->published_at?->toISOString(), 'public_url' => route('academy.courses.show', ['course' => $course->slug]), 'progress' => $progress ? [ 'completedRequired' => (int) ($progress['completed_required'] ?? 0), 'totalRequired' => (int) ($progress['total_required'] ?? 0), 'percent' => (int) ($progress['progress_percent'] ?? 0), 'completed' => (bool) ($progress['completed'] ?? false), ] : null, 'continue_url' => $viewer ? $course->getContinueUrl($viewer) : route('academy.courses.show', ['course' => $course->slug]), ]; } public function courseLessonPayload(AcademyCourseLesson $courseLesson, ?User $viewer, bool $includeFull = false, array $options = []): array { $lesson = $courseLesson->lesson; if (! $lesson instanceof AcademyLesson) { return []; } $authorized = $this->canAccessCourseLesson($viewer, $courseLesson); $payload = $this->lessonPayload($lesson, $viewer, $includeFull, $authorized); $payload['course_lesson_id'] = (int) $courseLesson->id; $payload['course_id'] = (int) $courseLesson->course_id; $payload['section_id'] = $courseLesson->section_id ? (int) $courseLesson->section_id : null; $payload['order_num'] = (int) ($courseLesson->order_num ?? 0); $payload['is_required'] = (bool) $courseLesson->is_required; $payload['access_override'] = $courseLesson->access_override; $payload['course_step_number'] = (int) ($options['course_step_number'] ?? 0); $payload['course_step_label'] = (string) ($options['course_step_label'] ?? ''); $payload['completed'] = in_array((int) $courseLesson->lesson_id, array_map('intval', (array) ($options['completed_lesson_ids'] ?? [])), true); $payload['course_url'] = route('academy.courses.lessons.show', ['course' => $courseLesson->course->slug, 'lesson' => $lesson->slug]); return $payload; } public function promptPayload(AcademyPromptTemplate $prompt, ?User $viewer, bool $includeFull = false): array { $authorized = $this->canAccessPrompt($viewer, $prompt); $publicExamples = $this->promptPublicExamplesPayload($prompt, (array) ($prompt->tool_notes ?? [])); $previewImage = $this->promptPreviewImagePayload((string) ($prompt->preview_image ?? '')); $documentation = $this->promptDocumentationPayload($prompt->documentation); $placeholders = $this->promptPlaceholdersPayload((array) ($prompt->placeholders ?? [])); $allFilledExamples = $this->promptFilledExamplesPayload((array) ($prompt->filled_examples ?? [])); $filledExamplesTotal = count($allFilledExamples); $hasFullFilledExamplesAccess = (bool) (($viewer?->hasAcademyProAccess() ?? false) || ($viewer?->hasStaffAccess() ?? false)); $hasPartialFilledExamplesAccess = (bool) ($viewer?->hasAcademyCreatorAccess() ?? false); $visibleFilledExamples = match (true) { ! $includeFull => [], $hasFullFilledExamplesAccess => $allFilledExamples, $hasPartialFilledExamplesAccess => array_slice($allFilledExamples, 0, 2), default => [], }; $hasPlaceholderInputs = $this->promptHasPlaceholderInputs((string) $prompt->prompt, $placeholders); $hasFilledExamples = $allFilledExamples !== []; $hasHelperPrompts = $this->promptHelperPromptsPayload((array) ($prompt->helper_prompts ?? [])) !== []; $hasPromptVariants = $this->promptVariantsPayload((array) ($prompt->prompt_variants ?? [])) !== []; $helperPrompts = $authorized && $includeFull ? $this->promptHelperPromptsPayload((array) ($prompt->helper_prompts ?? [])) : []; $promptVariants = $authorized && $includeFull ? $this->promptVariantsPayload((array) ($prompt->prompt_variants ?? [])) : []; return [ 'id' => (int) $prompt->id, 'title' => (string) $prompt->title, 'slug' => (string) $prompt->slug, 'excerpt' => (string) ($prompt->excerpt ?? ''), 'prompt' => ($authorized && $includeFull) ? (string) $prompt->prompt : null, 'negative_prompt' => ($authorized && $includeFull) ? (string) ($prompt->negative_prompt ?? '') : null, 'usage_notes' => ($authorized && $includeFull) ? (string) ($prompt->usage_notes ?? '') : null, 'workflow_notes' => ($authorized && $includeFull) ? (string) ($prompt->workflow_notes ?? '') : null, 'prompt_preview' => $authorized ? null : $this->previewText((string) $prompt->prompt, 220), 'documentation' => $documentation, 'placeholders' => $placeholders, 'has_placeholder_inputs' => $hasPlaceholderInputs, 'filled_examples' => $visibleFilledExamples, 'has_filled_examples' => $hasFilledExamples, 'filled_examples_total' => $filledExamplesTotal, 'can_access_filled_examples' => ($hasFullFilledExamplesAccess || $hasPartialFilledExamplesAccess) && $includeFull, 'has_more_filled_examples' => $filledExamplesTotal > count($visibleFilledExamples), 'has_full_filled_examples_access' => $hasFullFilledExamplesAccess, 'has_helper_prompts' => $hasHelperPrompts, 'has_prompt_variants' => $hasPromptVariants, 'helper_prompts' => $helperPrompts, 'prompt_variants' => $promptVariants, 'difficulty' => (string) $prompt->difficulty, 'access_level' => (string) $prompt->access_level, 'access_requirement' => $this->promptAccessRequirement((string) $prompt->access_level), 'unlock_heading' => $this->promptUnlockHeading((string) $prompt->access_level), 'unlock_description' => $this->promptUnlockDescription((string) $prompt->access_level), 'aspect_ratio' => $prompt->aspect_ratio, 'tags' => array_values((array) ($prompt->tags ?? [])), 'public_examples' => $publicExamples, 'tool_notes' => $authorized ? $this->promptToolNotesPayload((array) ($prompt->tool_notes ?? [])) : [], 'preview_image' => $previewImage['url'], 'preview_image_thumb' => $previewImage['thumb_url'], 'preview_image_srcset' => $previewImage['srcset'], 'featured' => (bool) $prompt->featured, 'prompt_of_week' => (bool) $prompt->prompt_of_week, 'published_at' => $prompt->published_at?->toISOString(), 'category' => $prompt->category ? [ 'id' => (int) $prompt->category->id, 'name' => (string) $prompt->category->name, 'slug' => (string) $prompt->category->slug, ] : null, 'locked' => ! $authorized, 'can_access' => $authorized, ]; } /** * @param array $filledExamples * @return array> */ private function promptFilledExamplesPayload(array $filledExamples): array { return collect($filledExamples) ->filter(static fn ($example): bool => is_array($example)) ->map(function (array $example): array { return [ 'title' => $this->nullableTrimmedString($example['title'] ?? null), 'description' => $this->nullableTrimmedString($example['description'] ?? null), 'placeholder_values' => collect(is_array($example['placeholder_values'] ?? null) ? $example['placeholder_values'] : []) ->mapWithKeys(function ($value, $key): array { $normalizedKey = trim((string) $key); if ($normalizedKey === '') { return []; } return [$normalizedKey => $value]; }) ->all(), 'prompt' => trim((string) ($example['prompt'] ?? '')), 'negative_prompt' => $this->nullableTrimmedString($example['negative_prompt'] ?? null), ]; }) ->filter(function (array $example): bool { return collect([ $example['title'] ?? null, $example['description'] ?? null, $example['prompt'] ?? null, $example['negative_prompt'] ?? null, $example['placeholder_values'] ?? null, ])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []); }) ->take(5) ->values() ->all(); } /** * @param mixed $documentation * @return array */ private function promptDocumentationPayload(mixed $documentation): array { $normalized = is_array($documentation) ? $documentation : []; $listFields = ['best_for', 'how_to_use', 'required_inputs', 'workflow', 'tips', 'common_mistakes', 'data_accuracy_notes']; $payload = [ 'summary' => $this->nullableTrimmedString($normalized['summary'] ?? null), 'display_notes' => $this->nullableTrimmedString($normalized['display_notes'] ?? null), ]; foreach ($listFields as $field) { $payload[$field] = $this->normalizeStringList($normalized[$field] ?? []); } return $payload; } /** * @param array $placeholders * @return array> */ private function promptPlaceholdersPayload(array $placeholders): array { return collect($placeholders) ->filter(static fn ($placeholder): bool => is_array($placeholder)) ->map(function (array $placeholder): array { return [ 'key' => trim((string) ($placeholder['key'] ?? '')), 'label' => $this->nullableTrimmedString($placeholder['label'] ?? null), 'description' => $this->nullableTrimmedString($placeholder['description'] ?? null), 'required' => filter_var($placeholder['required'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false, 'example' => $placeholder['example'] ?? null, 'default' => $placeholder['default'] ?? null, 'type' => $this->nullableTrimmedString($placeholder['type'] ?? null), ]; }) ->filter(function (array $placeholder): bool { return collect([ $placeholder['key'], $placeholder['label'], $placeholder['description'], $placeholder['example'], $placeholder['default'], $placeholder['type'], ])->contains(fn ($item): bool => $item !== null && $item !== '' && $item !== []); }) ->values() ->all(); } /** * @param array> $placeholders */ private function promptHasPlaceholderInputs(string $prompt, array $placeholders): bool { if ($prompt === '' || $placeholders === []) { return false; } foreach ($placeholders as $placeholder) { $key = trim((string) ($placeholder['key'] ?? '')); if ($key === '') { continue; } if (mb_stripos($prompt, '['.$key.']') !== false) { return true; } } return false; } /** * @param array $helperPrompts * @return array> */ private function promptHelperPromptsPayload(array $helperPrompts): array { return collect($helperPrompts) ->filter(static fn ($helperPrompt): bool => is_array($helperPrompt)) ->map(function (array $helperPrompt): array { return [ 'title' => trim((string) ($helperPrompt['title'] ?? '')), 'type' => trim((string) ($helperPrompt['type'] ?? 'other')) ?: 'other', 'description' => $this->nullableTrimmedString($helperPrompt['description'] ?? null), 'prompt' => trim((string) ($helperPrompt['prompt'] ?? '')), 'expected_output' => trim((string) ($helperPrompt['expected_output'] ?? 'text')) ?: 'text', 'active' => filter_var($helperPrompt['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, ]; }) ->filter(function (array $helperPrompt): bool { return $helperPrompt['active'] !== false && collect([ $helperPrompt['title'], $helperPrompt['description'], $helperPrompt['prompt'], ])->contains(fn ($item): bool => $item !== null && $item !== ''); }) ->values() ->all(); } /** * @param array $variants * @return array> */ private function promptVariantsPayload(array $variants): array { return collect($variants) ->filter(static fn ($variant): bool => is_array($variant)) ->map(function (array $variant): array { return [ 'title' => trim((string) ($variant['title'] ?? '')), 'slug' => $this->nullableTrimmedString($variant['slug'] ?? null), 'description' => $this->nullableTrimmedString($variant['description'] ?? null), 'prompt' => trim((string) ($variant['prompt'] ?? '')), 'negative_prompt' => $this->nullableTrimmedString($variant['negative_prompt'] ?? null), 'recommended' => filter_var($variant['recommended'] ?? false, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? false, 'recommended_for' => $this->normalizeStringList($variant['recommended_for'] ?? []), 'risk_notes' => $this->normalizeStringList($variant['risk_notes'] ?? []), 'active' => filter_var($variant['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, ]; }) ->filter(function (array $variant): bool { return $variant['active'] !== false && collect([ $variant['title'], $variant['description'], $variant['prompt'], $variant['negative_prompt'], ])->contains(fn ($item): bool => $item !== null && $item !== ''); }) ->values() ->all(); } /** * @param array $notes * @return array> */ private function promptPublicExamplesPayload(AcademyPromptTemplate $prompt, array $notes): array { $promptTitle = trim((string) $prompt->title); return collect($notes) ->values() ->filter(static fn ($note): bool => is_array($note)) ->filter(function (array $note): bool { return (filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true) !== false; }) ->map(function (array $note, int $index) use ($promptTitle): ?array { $imagePayload = $this->responsiveLessonImagePayload( (string) ($note['image_path'] ?? ''), (string) ($note['thumb_path'] ?? ''), ); $imagePath = $imagePayload['image_path']; $thumbPath = $imagePayload['thumb_path']; $imageUrl = $imagePayload['image_url']; $thumbUrl = $imagePayload['thumb_url']; if ($imageUrl === null && $thumbUrl === null) { return null; } $displayType = trim((string) ($note['display_type'] ?? '')); $provider = trim((string) ($note['provider'] ?? '')); $modelName = trim((string) ($note['model_name'] ?? '')); $typeLabel = $displayType !== '' ? (string) Str::of($displayType)->replace(['_', '-'], ' ')->headline() : 'Prompt variation'; $title = $displayType !== '' ? $typeLabel : ($modelName !== '' ? $modelName : ($provider !== '' ? $provider : sprintf('Prompt Example %02d', $index + 1))); $caption = $displayType !== '' ? sprintf('%s preview for %s.', $typeLabel, $promptTitle !== '' ? $promptTitle : 'this prompt') : sprintf('Example result preview for %s.', $promptTitle !== '' ? $promptTitle : 'this prompt'); return [ 'type_label' => $typeLabel, 'title' => $title, 'caption' => $caption, 'alt' => sprintf('%s preview image for %s', $title, $promptTitle !== '' ? $promptTitle : 'Skinbase Academy prompt'), 'provider' => $provider, 'model_name' => $modelName, 'image_path' => $imagePath, 'image_url' => $imageUrl, 'thumb_path' => $thumbPath, 'thumb_url' => $thumbUrl, 'image_srcset' => $imagePayload['srcset'], 'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null, ]; }) ->filter() ->values() ->all(); } private function promptAccessRequirement(string $accessLevel): ?string { return match (trim(strtolower($accessLevel))) { 'pro' => 'Requires Pro access.', 'creator' => 'Requires Creator or Pro access.', default => null, }; } private function promptUnlockHeading(string $accessLevel): ?string { return match (trim(strtolower($accessLevel))) { 'pro' => 'Unlock the full Pro prompt.', 'creator' => 'Unlock the full Creator prompt.', default => null, }; } private function promptUnlockDescription(string $accessLevel): ?string { return match (trim(strtolower($accessLevel))) { 'pro' => 'Get the complete reusable prompt, negative prompt, workflow notes, model settings, and variation strategy.', 'creator' => 'Get the complete reusable prompt, negative prompt, workflow notes, and creative workflow.', default => null, }; } /** * @param array $notes * @return array> */ private function promptToolNotesPayload(array $notes): array { return collect($notes) ->filter(static fn ($note): bool => is_array($note)) ->map(function (array $note): array { $imagePayload = $this->responsiveLessonImagePayload( (string) ($note['image_path'] ?? ''), (string) ($note['thumb_path'] ?? ''), ); return [ 'display_type' => trim((string) ($note['display_type'] ?? '')), 'provider' => trim((string) ($note['provider'] ?? '')), 'model_name' => trim((string) ($note['model_name'] ?? '')), 'notes' => trim((string) ($note['notes'] ?? '')), 'strengths' => trim((string) ($note['strengths'] ?? '')), 'weaknesses' => trim((string) ($note['weaknesses'] ?? '')), 'best_for' => trim((string) ($note['best_for'] ?? '')), 'image_path' => $imagePayload['image_path'], 'image_url' => $imagePayload['image_url'], 'thumb_path' => $imagePayload['thumb_path'], 'thumb_url' => $imagePayload['thumb_url'], 'image_srcset' => $imagePayload['srcset'], 'settings' => trim((string) ($note['settings'] ?? '')), 'score' => filled($note['score'] ?? null) ? (int) $note['score'] : null, 'active' => filter_var($note['active'] ?? true, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE) ?? true, ]; }) ->filter(function (array $note): bool { return collect([ $note['display_type'], $note['provider'], $note['model_name'], $note['notes'], $note['strengths'], $note['weaknesses'], $note['best_for'], $note['image_path'], $note['thumb_path'], $note['settings'], ])->contains(fn (string $value): bool => $value !== '') || $note['score'] !== null; }) ->values() ->all(); } public function packPayload(AcademyPromptPack $pack, ?User $viewer, bool $includePrompts = false): array { $authorized = $this->canAccessPack($viewer, $pack); return [ 'id' => (int) $pack->id, 'title' => (string) $pack->title, 'slug' => (string) $pack->slug, 'excerpt' => (string) ($pack->excerpt ?? ''), 'description' => (string) ($pack->description ?? ''), 'access_level' => (string) $pack->access_level, 'cover_image' => $pack->cover_image, 'tags' => array_values((array) ($pack->tags ?? [])), 'featured' => (bool) $pack->featured, 'published_at' => $pack->published_at?->toISOString(), 'locked' => ! $authorized, 'can_access' => $authorized, 'prompts' => $includePrompts ? $pack->prompts->map(fn (AcademyPromptTemplate $prompt): array => $this->promptPayload($prompt, $viewer, $authorized))->values()->all() : [], ]; } public function challengePayload(AcademyChallenge $challenge, ?User $viewer, bool $includeSubmissions = false): array { $authorized = $this->canAccessChallenge($viewer, $challenge); return [ 'id' => (int) $challenge->id, 'title' => (string) $challenge->title, 'slug' => (string) $challenge->slug, 'excerpt' => (string) ($challenge->excerpt ?? ''), 'description' => (string) ($challenge->description ?? ''), 'brief' => (string) ($challenge->brief ?? ''), 'rules' => (string) ($challenge->rules ?? ''), 'access_level' => (string) $challenge->access_level, 'status' => (string) $challenge->status, 'starts_at' => $challenge->starts_at?->toISOString(), 'ends_at' => $challenge->ends_at?->toISOString(), 'voting_starts_at' => $challenge->voting_starts_at?->toISOString(), 'voting_ends_at' => $challenge->voting_ends_at?->toISOString(), 'cover_image' => $challenge->cover_image, 'prize_text' => $challenge->prize_text, 'required_tags' => array_values((array) ($challenge->required_tags ?? [])), 'allowed_categories' => array_values((array) ($challenge->allowed_categories ?? [])), 'featured' => (bool) $challenge->featured, 'locked' => ! $authorized, 'can_access' => $authorized, 'submission_count' => $includeSubmissions ? (int) $challenge->submissions()->approved()->count() : null, ]; } private function rankForLevel(string $accessLevel): int { return match ($this->normalizeAccessLevel($accessLevel)) { 'admin' => 99, 'pro' => 30, 'creator' => 20, default => 10, }; } private function normalizeAccessLevel(string $accessLevel): string { return match (Str::lower(trim($accessLevel))) { 'admin' => 'admin', 'pro' => 'pro', 'creator', 'premium' => 'creator', 'mixed' => 'free', default => 'free', }; } private function tierLabel(string $tier): string { return match ($this->normalizeAccessLevel($tier)) { 'admin' => 'Admin', 'pro' => 'Pro', 'creator' => 'Creator', default => 'Free', }; } private function isAcademyAdmin(User $user): bool { return $user->hasStaffAccess() || $user->isModerator(); } private function resolveSubscriptionTier(User $user): ?string { $subscription = $this->activeAcademySubscription($user); if (! $subscription instanceof Subscription) { return null; } $matchedTier = null; foreach ($subscription->items as $item) { $priceId = trim((string) $item->stripe_price); if ($priceId === '') { continue; } $tier = $this->priceTierMap()[$priceId] ?? null; if ($tier === null) { continue; } if ($matchedTier === null || $this->rankForLevel($tier) > $this->rankForLevel($matchedTier)) { $matchedTier = $tier; } } return $matchedTier; } private function resolveLegacyPaidTier(User $user): ?string { return match (Str::lower(trim((string) ($user->role ?? '')))) { 'academy_pro' => 'pro', 'academy_creator' => 'creator', default => null, }; } private function activeAcademySubscription(User $user): ?Subscription { $cacheKey = (int) $user->getKey(); if (array_key_exists($cacheKey, $this->subscriptionCache)) { return $this->subscriptionCache[$cacheKey]; } $subscription = $user->subscription($this->subscriptionName()); if (! $subscription instanceof Subscription) { return $this->subscriptionCache[$cacheKey] = null; } if (! $subscription->active() && ! $subscription->onGracePeriod()) { return $this->subscriptionCache[$cacheKey] = null; } return $this->subscriptionCache[$cacheKey] = $subscription->loadMissing('items'); } /** * @return array */ private function priceTierMap(): array { if (is_array($this->priceTierMap)) { return $this->priceTierMap; } $map = []; foreach ((array) config('academy_billing.plans', []) as $plan) { if (! is_array($plan)) { continue; } $priceId = trim((string) ($plan['stripe_price_id'] ?? '')); $tier = $this->normalizeAccessLevel((string) ($plan['tier'] ?? 'free')); if ($priceId === '' || $tier === 'free') { continue; } $map[$priceId] = $tier; } return $this->priceTierMap = $map; } private function subscriptionName(): string { return (string) config('academy_billing.subscription_name', 'academy'); } private function previewText(string $value, int $limit): string { $plain = trim(strip_tags($value)); if ($plain === '') { return ''; } $length = mb_strlen($plain); $previewLength = min($limit, max(12, (int) ceil($length * 0.55))); if ($previewLength >= $length) { $previewLength = max(1, $length - 1); } return rtrim(mb_substr($plain, 0, $previewLength)).'...'; } private function nullableTrimmedString(mixed $value): ?string { if ($value === null) { return null; } $normalized = trim((string) $value); return $normalized !== '' ? $normalized : null; } /** * @return array */ private function normalizeStringList(mixed $value): array { if (! is_array($value)) { $value = $value === null ? [] : [$value]; } return collect($value) ->map(fn ($item): string => trim((string) $item)) ->filter(static fn (string $item): bool => $item !== '') ->values() ->all(); } private function resolvePreviewImageUrl(string $previewImage): ?string { $previewImage = trim($previewImage); if ($previewImage === '') { return null; } if (str_starts_with($previewImage, 'http://') || str_starts_with($previewImage, 'https://') || str_starts_with($previewImage, '/')) { return $previewImage; } return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($previewImage); } /** * @return array{url:?string,thumb_url:?string,srcset:?string} */ private function promptPreviewImagePayload(string $previewImage): array { $url = $this->resolvePreviewImageUrl($previewImage); $thumbPath = $this->existingResponsiveVariantPath($previewImage, 'thumb'); $mediumPath = $this->existingResponsiveVariantPath($previewImage, 'md'); return [ 'url' => $url, 'thumb_url' => $thumbPath !== null ? $this->resolvePreviewImageUrl($thumbPath) : $url, 'srcset' => $this->buildResponsiveSrcset([ ['url' => $thumbPath !== null ? $this->resolvePreviewImageUrl($thumbPath) : null, 'width' => 480], ['url' => $mediumPath !== null ? $this->resolvePreviewImageUrl($mediumPath) : null, 'width' => 960], ]), ]; } private function resolveLessonCoverImageUrl(string $coverImage): ?string { $coverImage = trim($coverImage); if ($coverImage === '') { return null; } if (str_starts_with($coverImage, 'http://') || str_starts_with($coverImage, 'https://') || str_starts_with($coverImage, '/')) { return $coverImage; } return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($coverImage); } private function resolveLessonMediaUrl(string $path): ?string { $path = trim($path); if ($path === '') { return null; } if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) { return $path; } return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($path); } /** * @return array{image_path:string,image_url:?string,thumb_path:string,thumb_url:?string,srcset:?string} */ private function responsiveLessonImagePayload(string $imagePath, string $thumbPath = ''): array { $resolvedImagePath = trim($imagePath); $resolvedThumbPath = trim($thumbPath); $imageUrl = $this->resolveLessonMediaUrl($resolvedImagePath); $thumbUrl = $resolvedThumbPath !== '' ? $this->resolveLessonMediaUrl($resolvedThumbPath) : $imageUrl; $mediumPath = $resolvedThumbPath !== '' ? $this->existingResponsiveVariantPath($resolvedImagePath, 'md') : null; return [ 'image_path' => $resolvedImagePath, 'image_url' => $imageUrl, 'thumb_path' => $resolvedThumbPath, 'thumb_url' => $thumbUrl, 'srcset' => $this->buildResponsiveSrcset([ ['url' => $thumbUrl, 'width' => $resolvedThumbPath !== '' ? 480 : null], ['url' => $mediumPath !== null ? $this->resolveLessonMediaUrl($mediumPath) : null, 'width' => $mediumPath !== null ? 960 : null], ]), ]; } private function responsiveVariantPath(string $path, string $variant): ?string { $path = trim($path); if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) { return null; } $directory = pathinfo($path, PATHINFO_DIRNAME); $filename = pathinfo($path, PATHINFO_FILENAME); $baseFilename = preg_replace('/-(thumb|md)$/', '', $filename) ?? $filename; return sprintf('%s/%s-%s.webp', $directory, $baseFilename, $variant); } private function existingResponsiveVariantPath(string $path, string $variant): ?string { $variantPath = $this->responsiveVariantPath($path, $variant); if ($variantPath === null || ! $this->storagePathExists($variantPath)) { return null; } return $variantPath; } private function storagePathExists(string $path): bool { $normalizedPath = trim($path); if ($normalizedPath === '' || str_starts_with($normalizedPath, 'http://') || str_starts_with($normalizedPath, 'https://') || str_starts_with($normalizedPath, '/')) { return false; } $cacheKey = (string) config('uploads.object_storage.disk', 's3') . ':' . $normalizedPath; if (array_key_exists($cacheKey, $this->assetExistsCache)) { return $this->assetExistsCache[$cacheKey]; } try { $exists = Storage::disk((string) config('uploads.object_storage.disk', 's3'))->exists($normalizedPath); } catch (\Throwable) { $exists = false; } $this->assetExistsCache[$cacheKey] = $exists; return $exists; } /** * @param array $variants */ private function buildResponsiveSrcset(array $variants): ?string { $entries = collect($variants) ->filter(static fn (array $variant): bool => filled($variant['url'] ?? null) && (int) ($variant['width'] ?? 0) > 0) ->unique(fn (array $variant): string => (string) $variant['url']) ->map(fn (array $variant): string => sprintf('%s %dw', (string) $variant['url'], (int) $variant['width'])) ->values() ->all(); return $entries !== [] ? implode(', ', $entries) : null; } /** * @return array|null */ private function lessonBlockPayload(AcademyLessonBlock $block): ?array { if ($block->type !== 'ai_comparison') { return null; } $payload = is_array($block->payload) ? $block->payload : []; $criteria = collect($payload['criteria'] ?? []) ->map(static fn ($criterion): string => trim((string) $criterion)) ->filter(static fn (string $criterion): bool => $criterion !== '') ->values() ->all(); $results = $block->activeComparisonResults ->map(function (AcademyAiComparisonResult $result): array { $imagePayload = $this->responsiveLessonImagePayload( (string) $result->image_path, (string) ($result->thumb_path ?? ''), ); return [ 'id' => (int) $result->id, 'provider' => (string) ($result->provider ?? ''), 'model_name' => (string) ($result->model_name ?? ''), 'image_path' => $imagePayload['image_path'], 'image_url' => $imagePayload['image_url'], 'thumb_path' => $imagePayload['thumb_path'], 'thumb_url' => $imagePayload['thumb_url'], 'image_srcset' => $imagePayload['srcset'], 'settings' => (string) ($result->settings ?? ''), 'strengths' => (string) ($result->strengths ?? ''), 'weaknesses' => (string) ($result->weaknesses ?? ''), 'best_for' => (string) ($result->best_for ?? ''), 'score' => $result->score, 'sort_order' => (int) $result->sort_order, 'active' => (bool) $result->active, ]; }) ->values() ->all(); $hasPromptData = filled($payload['prompt'] ?? null) || filled($payload['negative_prompt'] ?? null) || filled($payload['intro'] ?? null) || filled($payload['title'] ?? null) || filled($payload['aspect_ratio'] ?? null) || ! empty($criteria); if (! $hasPromptData && $results === []) { return null; } return [ 'id' => (int) $block->id, 'type' => (string) $block->type, 'title' => (string) ($block->title ?? ($payload['title'] ?? '')), 'payload' => [ 'title' => (string) ($payload['title'] ?? ''), 'intro' => (string) ($payload['intro'] ?? ''), 'prompt' => (string) ($payload['prompt'] ?? ''), 'negative_prompt' => (string) ($payload['negative_prompt'] ?? ''), 'aspect_ratio' => (string) ($payload['aspect_ratio'] ?? ''), 'criteria' => $criteria, ], 'sort_order' => (int) $block->sort_order, 'active' => (bool) $block->active, 'comparison_results' => $results, ]; } private function injectHeadingIds(string $html): string { if ($html === '') { return $html; } $seenIds = []; return preg_replace_callback( '/<(h[23])([^>]*)>(.*?)<\/\1>/si', function (array $m) use (&$seenIds): string { [, $tag, $attrs, $inner] = $m; if (preg_match('/\bid\s*=/i', $attrs)) { return $m[0]; } $textContent = strip_tags($inner); $baseId = $this->slugifyHeading($textContent); $count = $seenIds[$baseId] ?? 0; $id = $count > 0 ? $baseId.'-'.($count + 1) : $baseId; $seenIds[$baseId] = $count + 1; return "<{$tag} id=\"{$id}\"{$attrs}>{$inner}"; }, $html ) ?? $html; } private function slugifyHeading(string $text): string { $slug = mb_strtolower($text); $slug = preg_replace('/[^a-z0-9_\s\-]/', '', $slug) ?? ''; $slug = preg_replace('/\s+/', '-', trim($slug)) ?? ''; $slug = preg_replace('/-+/', '-', $slug) ?? ''; $slug = trim($slug, '-'); return $slug !== '' ? $slug : 'section'; } }