Implement academy analytics, billing, and web stories updates
This commit is contained in:
@@ -15,11 +15,39 @@ use App\Models\AcademyPromptTemplate;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
final class AcademyAccessService
|
||||
{
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private array $assetExistsCache = [];
|
||||
|
||||
/**
|
||||
* @var array<int, string|null>
|
||||
*/
|
||||
private array $paidTierCache = [];
|
||||
|
||||
/**
|
||||
* @var array<int, Subscription|null>
|
||||
*/
|
||||
private array $subscriptionCache = [];
|
||||
|
||||
/**
|
||||
* @var array<string, string>|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;
|
||||
}
|
||||
@@ -28,11 +56,40 @@ final class AcademyAccessService
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isAdmin()) {
|
||||
return true;
|
||||
return $this->rankForLevel($this->currentTier($user)) >= $this->rankForLevel($accessLevel);
|
||||
}
|
||||
|
||||
public function currentTier(?User $user): string
|
||||
{
|
||||
if (! $user instanceof User) {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
return $this->rankForUser($user) >= $this->rankForLevel($accessLevel);
|
||||
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;
|
||||
}
|
||||
|
||||
public function canAccessLesson(?User $user, AcademyLesson $lesson): bool
|
||||
@@ -59,11 +116,7 @@ final class AcademyAccessService
|
||||
{
|
||||
$accessLevel = trim((string) ($courseLesson->access_override ?: $courseLesson->lesson?->access_level ?: 'free'));
|
||||
|
||||
if ($accessLevel === 'premium') {
|
||||
return $user?->isAdmin() ?? false;
|
||||
}
|
||||
|
||||
return $this->canAccessContent($user, $accessLevel === 'mixed' ? 'free' : $accessLevel);
|
||||
return $this->canAccessContent($user, $accessLevel);
|
||||
}
|
||||
|
||||
public function lessonPayload(AcademyLesson $lesson, ?User $viewer, bool $includeFull = false, ?bool $authorizedOverride = null): array
|
||||
@@ -172,6 +225,19 @@ final class AcademyAccessService
|
||||
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 ?? []));
|
||||
$hasPlaceholderInputs = $this->promptHasPlaceholderInputs((string) $prompt->prompt, $placeholders);
|
||||
$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,
|
||||
@@ -183,12 +249,25 @@ final class AcademyAccessService
|
||||
'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,
|
||||
'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' => $this->resolvePreviewImageUrl((string) ($prompt->preview_image ?? '')),
|
||||
'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(),
|
||||
@@ -202,6 +281,235 @@ final class AcademyAccessService
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $documentation
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<int, mixed> $placeholders
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array<string, mixed>> $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<int, mixed> $helperPrompts
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, mixed> $variants
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, mixed> $notes
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, mixed> $notes
|
||||
* @return array<int, array<string, mixed>>
|
||||
@@ -211,17 +519,24 @@ final class AcademyAccessService
|
||||
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' => trim((string) ($note['image_path'] ?? '')),
|
||||
'image_url' => $this->resolveLessonMediaUrl((string) ($note['image_path'] ?? '')),
|
||||
'thumb_path' => trim((string) ($note['thumb_path'] ?? '')),
|
||||
'thumb_url' => $this->resolveLessonMediaUrl((string) ($note['thumb_path'] ?? '')),
|
||||
'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,
|
||||
@@ -229,6 +544,7 @@ final class AcademyAccessService
|
||||
})
|
||||
->filter(function (array $note): bool {
|
||||
return collect([
|
||||
$note['display_type'],
|
||||
$note['provider'],
|
||||
$note['model_name'],
|
||||
$note['notes'],
|
||||
@@ -296,30 +612,127 @@ final class AcademyAccessService
|
||||
];
|
||||
}
|
||||
|
||||
private function rankForUser(User $user): int
|
||||
{
|
||||
if (method_exists($user, 'hasAcademyProAccess') && $user->hasAcademyProAccess()) {
|
||||
return $this->rankForLevel('pro');
|
||||
}
|
||||
|
||||
if (method_exists($user, 'hasAcademyCreatorAccess') && $user->hasAcademyCreatorAccess()) {
|
||||
return $this->rankForLevel('creator');
|
||||
}
|
||||
|
||||
return $this->rankForLevel('free');
|
||||
}
|
||||
|
||||
private function rankForLevel(string $accessLevel): int
|
||||
{
|
||||
return match (Str::lower(trim($accessLevel))) {
|
||||
return match ($this->normalizeAccessLevel($accessLevel)) {
|
||||
'admin' => 99,
|
||||
'premium' => 40,
|
||||
'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 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<string, string>
|
||||
*/
|
||||
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));
|
||||
@@ -338,6 +751,33 @@ final class AcademyAccessService
|
||||
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<int, string>
|
||||
*/
|
||||
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);
|
||||
@@ -353,6 +793,25 @@ final class AcademyAccessService
|
||||
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);
|
||||
@@ -383,6 +842,95 @@ final class AcademyAccessService
|
||||
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<int, array{url:?string,width:int|null}> $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<string, mixed>|null
|
||||
*/
|
||||
@@ -399,22 +947,30 @@ final class AcademyAccessService
|
||||
->values()
|
||||
->all();
|
||||
$results = $block->activeComparisonResults
|
||||
->map(fn (AcademyAiComparisonResult $result): array => [
|
||||
'id' => (int) $result->id,
|
||||
'provider' => (string) ($result->provider ?? ''),
|
||||
'model_name' => (string) ($result->model_name ?? ''),
|
||||
'image_path' => (string) $result->image_path,
|
||||
'image_url' => $this->resolveLessonMediaUrl((string) $result->image_path),
|
||||
'thumb_path' => (string) ($result->thumb_path ?? ''),
|
||||
'thumb_url' => $this->resolveLessonMediaUrl((string) ($result->thumb_path ?? '')),
|
||||
'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,
|
||||
])
|
||||
->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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user