From 874f8feb9c94d2cb3925dcb40a95bc3c366ea856 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Fri, 1 May 2026 11:43:08 +0200 Subject: [PATCH] Add homepage announcement module --- .../HomepageAnnouncementController.php | 451 ++++++++++++ .../UpsertHomepageAnnouncementRequest.php | 98 +++ app/Models/HomepageAnnouncement.php | 197 ++++++ .../HomepageAnnouncementObserver.php | 31 + .../HomepageAnnouncementSanitizer.php | 175 +++++ app/Services/HomepageAnnouncementService.php | 238 +++++++ app/Services/HomepageService.php | 16 +- ...00_create_homepage_announcements_table.php | 71 ++ .../HomepageAnnouncementLaunchSeeder.php | 59 ++ .../Admin/HomepageAnnouncements/Form.jsx | 667 ++++++++++++++++++ .../Admin/HomepageAnnouncements/Index.jsx | 110 +++ .../homepage/HomepageAnnouncement.jsx | 241 +++++++ .../homepage/HomepageAnnouncementEditor.jsx | 172 +++++ .../views/web/home/announcement.blade.php | 61 ++ .../HomepageAnnouncementServiceTest.php | 97 +++ tests/Unit/HomepageAnnouncementModuleTest.php | 286 ++++++++ 16 files changed, 2968 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/Settings/HomepageAnnouncementController.php create mode 100644 app/Http/Requests/Settings/UpsertHomepageAnnouncementRequest.php create mode 100644 app/Models/HomepageAnnouncement.php create mode 100644 app/Observers/HomepageAnnouncementObserver.php create mode 100644 app/Services/HomepageAnnouncementSanitizer.php create mode 100644 app/Services/HomepageAnnouncementService.php create mode 100644 database/migrations/2026_05_01_120000_create_homepage_announcements_table.php create mode 100644 database/seeders/HomepageAnnouncementLaunchSeeder.php create mode 100644 resources/js/Pages/Admin/HomepageAnnouncements/Form.jsx create mode 100644 resources/js/Pages/Admin/HomepageAnnouncements/Index.jsx create mode 100644 resources/js/components/homepage/HomepageAnnouncement.jsx create mode 100644 resources/js/components/homepage/HomepageAnnouncementEditor.jsx create mode 100644 resources/views/web/home/announcement.blade.php create mode 100644 tests/Feature/HomepageAnnouncementServiceTest.php create mode 100644 tests/Unit/HomepageAnnouncementModuleTest.php diff --git a/app/Http/Controllers/Settings/HomepageAnnouncementController.php b/app/Http/Controllers/Settings/HomepageAnnouncementController.php new file mode 100644 index 00000000..0b10c981 --- /dev/null +++ b/app/Http/Controllers/Settings/HomepageAnnouncementController.php @@ -0,0 +1,451 @@ +latest('updated_at') + ->paginate(20) + ->withQueryString(); + + $announcements->getCollection()->transform(fn (HomepageAnnouncement $announcement): array => $this->serializeForIndex($announcement)); + + return Inertia::render('Admin/HomepageAnnouncements/Index', [ + 'announcements' => $announcements, + 'createUrl' => route('admin.homepage-announcements.create'), + ]); + } + + public function create(): Response + { + $form = $this->blankForm(); + + return Inertia::render('Admin/HomepageAnnouncements/Form', [ + 'announcement' => $form, + 'previewAnnouncement' => $this->announcements->previewPayload($form), + 'options' => $this->options(), + 'submitUrl' => route('admin.homepage-announcements.store'), + 'previewUrl' => route('admin.homepage-announcements.preview'), + 'indexUrl' => route('admin.homepage-announcements.index'), + 'destroyUrl' => null, + ]); + } + + public function store(UpsertHomepageAnnouncementRequest $request): RedirectResponse + { + $actor = $this->currentActor($request); + $announcement = new HomepageAnnouncement(); + $attributes = $this->persistedAttributes($request, $announcement); + $attributes['created_by'] = (int) $actor->id; + $attributes['updated_by'] = (int) $actor->id; + + $announcement->forceFill($attributes)->save(); + + return redirect() + ->route('admin.homepage-announcements.edit', ['homepageAnnouncement' => $announcement]) + ->with('success', 'Homepage announcement created.'); + } + + public function edit(HomepageAnnouncement $homepageAnnouncement): Response + { + $form = $this->serializeForForm($homepageAnnouncement); + + return Inertia::render('Admin/HomepageAnnouncements/Form', [ + 'announcement' => $form, + 'previewAnnouncement' => $this->announcements->toHomepagePayload($homepageAnnouncement), + 'options' => $this->options(), + 'submitUrl' => route('admin.homepage-announcements.update', ['homepageAnnouncement' => $homepageAnnouncement]), + 'previewUrl' => route('admin.homepage-announcements.preview'), + 'indexUrl' => route('admin.homepage-announcements.index'), + 'destroyUrl' => route('admin.homepage-announcements.destroy', ['homepageAnnouncement' => $homepageAnnouncement]), + ]); + } + + public function update(UpsertHomepageAnnouncementRequest $request, HomepageAnnouncement $homepageAnnouncement): RedirectResponse + { + $actor = $this->currentActor($request); + $attributes = $this->persistedAttributes($request, $homepageAnnouncement); + $attributes['updated_by'] = (int) $actor->id; + + $homepageAnnouncement->forceFill($attributes)->save(); + + return redirect() + ->route('admin.homepage-announcements.edit', ['homepageAnnouncement' => $homepageAnnouncement]) + ->with('success', 'Homepage announcement updated.'); + } + + public function destroy(HomepageAnnouncement $homepageAnnouncement): RedirectResponse + { + $homepageAnnouncement->delete(); + + return redirect() + ->route('admin.homepage-announcements.index') + ->with('success', 'Homepage announcement deleted.'); + } + + public function preview(UpsertHomepageAnnouncementRequest $request): JsonResponse + { + $attributes = $this->persistedAttributes($request, null, false); + + return response()->json([ + 'ok' => true, + 'announcement' => $this->announcements->previewPayload($attributes), + ]); + } + + /** + * @return array + */ + private function persistedAttributes(UpsertHomepageAnnouncementRequest $request, ?HomepageAnnouncement $announcement = null, bool $persistFile = true): array + { + $validated = $request->validated(); + unset($validated['background_image_file'], $validated['remove_background_image']); + + $validated = $this->announcements->sanitizeAttributes($validated); + + $validated = $this->normalizeLinkAttributes($validated, 'primary'); + $validated = $this->normalizeLinkAttributes($validated, 'secondary'); + + $currentBackground = $announcement?->background_image; + $backgroundImageFile = $this->backgroundImageUpload($request); + + if ($request->boolean('remove_background_image')) { + if ($persistFile) { + $this->deleteStoredBackgroundIfLocal($currentBackground); + } + + $validated['background_image'] = null; + } elseif ($persistFile && $backgroundImageFile instanceof UploadedFile) { + $this->deleteStoredBackgroundIfLocal($currentBackground); + $validated['background_image'] = $this->storeBackgroundImage($backgroundImageFile); + } elseif (! array_key_exists('background_image', $validated) && $announcement) { + $validated['background_image'] = $announcement->background_image; + } + + $validated['background_type'] = filled($validated['background_image'] ?? null) ? 'image' : null; + + return $validated; + } + + /** + * @param array $attributes + * @return array + */ + private function normalizeLinkAttributes(array $attributes, string $prefix): array + { + $type = (string) ($attributes[$prefix . '_link_type'] ?? HomepageAnnouncement::LINK_TYPE_NONE); + + if ($type === HomepageAnnouncement::LINK_TYPE_NONE) { + $attributes[$prefix . '_link_label'] = null; + $attributes[$prefix . '_link_url'] = null; + $attributes[$prefix . '_link_target_type'] = null; + $attributes[$prefix . '_link_target_id'] = null; + + return $attributes; + } + + $attributes[$prefix . '_link_label'] = filled($attributes[$prefix . '_link_label'] ?? null) + ? trim((string) $attributes[$prefix . '_link_label']) + : null; + $attributes[$prefix . '_link_url'] = filled($attributes[$prefix . '_link_url'] ?? null) + ? trim((string) $attributes[$prefix . '_link_url']) + : null; + $attributes[$prefix . '_link_target_id'] = filled($attributes[$prefix . '_link_target_id'] ?? null) + ? (int) $attributes[$prefix . '_link_target_id'] + : null; + + if (in_array($type, [HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, HomepageAnnouncement::LINK_TYPE_EXPLORE, HomepageAnnouncement::LINK_TYPE_UPLOAD], true)) { + $attributes[$prefix . '_link_target_type'] = null; + $attributes[$prefix . '_link_target_id'] = null; + + return $attributes; + } + + $attributes[$prefix . '_link_target_type'] = $type; + + return $attributes; + } + + private function deleteStoredBackgroundIfLocal(?string $path): void + { + $path = trim((string) $path); + if ($path === '' || str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) { + return; + } + + if (Storage::disk('public')->exists($path)) { + Storage::disk('public')->delete($path); + } + } + + private function backgroundImageUpload(UpsertHomepageAnnouncementRequest $request): ?UploadedFile + { + $file = $request->file('background_image_file'); + + if (! $file instanceof UploadedFile) { + return null; + } + + $pathName = trim((string) $file->getPathname()); + if ($file->isValid() && $pathName !== '' && is_file($pathName) && is_readable($pathName)) { + return $file; + } + + throw ValidationException::withMessages([ + 'background_image_file' => $this->backgroundUploadErrorMessage($file), + ]); + } + + private function storeBackgroundImage(UploadedFile $file): string + { + $pathName = trim((string) $file->getPathname()); + if ($pathName === '' || ! is_file($pathName) || ! is_readable($pathName)) { + throw ValidationException::withMessages([ + 'background_image_file' => $this->backgroundUploadErrorMessage($file), + ]); + } + + if (! function_exists('imagecreatefromstring') || ! function_exists('imagewebp')) { + throw ValidationException::withMessages([ + 'background_image_file' => 'The server is missing WebP image support. Enable the GD WebP extension to upload announcement backgrounds.', + ]); + } + + $binary = @file_get_contents($pathName); + if ($binary === false) { + throw ValidationException::withMessages([ + 'background_image_file' => 'The uploaded background image could not be opened for conversion. Please choose the file again and retry.', + ]); + } + + $image = @imagecreatefromstring($binary); + if (! $image instanceof \GdImage) { + throw ValidationException::withMessages([ + 'background_image_file' => 'The uploaded background image format could not be converted. Please use JPG, PNG, or WEBP.', + ]); + } + + try { + if (! imageistruecolor($image)) { + imagepalettetotruecolor($image); + } + + imagealphablending($image, true); + imagesavealpha($image, true); + + ob_start(); + $converted = imagewebp($image, null, self::BACKGROUND_WEBP_QUALITY); + $webpBinary = ob_get_clean(); + + if (! $converted || ! is_string($webpBinary) || $webpBinary === '') { + throw ValidationException::withMessages([ + 'background_image_file' => 'The uploaded background image could not be converted to WebP. Please try a different image.', + ]); + } + + $storedPath = 'homepage-announcements/' . pathinfo(Str::replace('\\', '/', $file->hashName()), PATHINFO_FILENAME) . '.webp'; + Storage::disk('public')->put($storedPath, $webpBinary, ['visibility' => 'public']); + } finally { + imagedestroy($image); + } + + return $storedPath; + } + + private function backgroundUploadErrorMessage(UploadedFile $file): string + { + return match ($file->getError()) { + UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'The uploaded background image exceeds the server upload limit.', + UPLOAD_ERR_PARTIAL => 'The uploaded background image was only partially received. Please retry the upload.', + UPLOAD_ERR_NO_TMP_DIR => 'The server upload temp directory is unavailable. Check PHP upload temp configuration.', + UPLOAD_ERR_CANT_WRITE => 'The server could not write the uploaded background image to temporary storage.', + UPLOAD_ERR_EXTENSION => 'A PHP extension blocked the background image upload.', + default => 'The uploaded background image could not be read. Please choose the file again and retry.', + }; + } + + /** + * @return array + */ + private function blankForm(): array + { + return [ + 'id' => null, + 'title' => '', + 'subtitle' => '', + 'badge_text' => '', + 'content_html' => '', + 'type' => HomepageAnnouncement::TYPE_ANNOUNCEMENT, + 'status' => HomepageAnnouncement::STATUS_DRAFT, + 'is_active' => true, + 'starts_at' => '', + 'ends_at' => '', + 'priority' => 0, + 'is_dismissible' => true, + 'dismiss_version' => 1, + 'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA, + 'theme_preset' => 'launch', + 'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED, + 'text_color' => '', + 'overlay_opacity' => 55, + 'background_image' => '', + 'background_image_url' => null, + 'remove_background_image' => false, + 'background_image_file' => null, + 'primary_link_label' => '', + 'primary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE, + 'primary_link_url' => '', + 'primary_link_target_id' => '', + 'secondary_link_label' => '', + 'secondary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE, + 'secondary_link_url' => '', + 'secondary_link_target_id' => '', + ]; + } + + /** + * @return array + */ + private function serializeForForm(HomepageAnnouncement $announcement): array + { + return [ + 'id' => (int) $announcement->id, + 'title' => (string) $announcement->title, + 'subtitle' => (string) ($announcement->subtitle ?? ''), + 'badge_text' => (string) ($announcement->badge_text ?? ''), + 'content_html' => (string) ($announcement->content_html ?? ''), + 'type' => (string) $announcement->type, + 'status' => (string) $announcement->status, + 'is_active' => (bool) $announcement->is_active, + 'starts_at' => optional($announcement->starts_at)?->format('Y-m-d\TH:i') ?? '', + 'ends_at' => optional($announcement->ends_at)?->format('Y-m-d\TH:i') ?? '', + 'priority' => (int) $announcement->priority, + 'is_dismissible' => (bool) $announcement->is_dismissible, + 'dismiss_version' => (int) $announcement->dismiss_version, + 'gradient_preset' => (string) ($announcement->gradient_preset ?? ''), + 'theme_preset' => (string) ($announcement->theme_preset ?? ''), + 'placement' => (string) $announcement->placement, + 'text_color' => (string) ($announcement->text_color ?? ''), + 'overlay_opacity' => (int) ($announcement->overlay_opacity ?? 55), + 'background_image' => (string) ($announcement->background_image ?? ''), + 'background_image_url' => $this->announcements->toHomepagePayload($announcement)['background_image_url'] ?? null, + 'remove_background_image' => false, + 'background_image_file' => null, + 'primary_link_label' => (string) ($announcement->primary_link_label ?? ''), + 'primary_link_type' => (string) ($announcement->primary_link_type ?? HomepageAnnouncement::LINK_TYPE_NONE), + 'primary_link_url' => (string) ($announcement->primary_link_url ?? ''), + 'primary_link_target_id' => $announcement->primary_link_target_id ? (string) $announcement->primary_link_target_id : '', + 'secondary_link_label' => (string) ($announcement->secondary_link_label ?? ''), + 'secondary_link_type' => (string) ($announcement->secondary_link_type ?? HomepageAnnouncement::LINK_TYPE_NONE), + 'secondary_link_url' => (string) ($announcement->secondary_link_url ?? ''), + 'secondary_link_target_id' => $announcement->secondary_link_target_id ? (string) $announcement->secondary_link_target_id : '', + ]; + } + + /** + * @return array + */ + private function serializeForIndex(HomepageAnnouncement $announcement): array + { + return [ + 'id' => (int) $announcement->id, + 'title' => (string) $announcement->title, + 'type' => (string) $announcement->type, + 'status' => (string) $announcement->status, + 'is_active' => (bool) $announcement->is_active, + 'priority' => (int) $announcement->priority, + 'dismiss_version' => (int) $announcement->dismiss_version, + 'placement' => (string) $announcement->placement, + 'starts_at' => optional($announcement->starts_at)?->toIso8601String(), + 'ends_at' => optional($announcement->ends_at)?->toIso8601String(), + 'updated_at' => optional($announcement->updated_at)?->toIso8601String(), + 'edit_url' => route('admin.homepage-announcements.edit', ['homepageAnnouncement' => $announcement]), + 'destroy_url' => route('admin.homepage-announcements.destroy', ['homepageAnnouncement' => $announcement]), + 'preview' => $this->announcements->toHomepagePayload($announcement), + ]; + } + + /** + * @return array>> + */ + private function options(): array + { + return [ + 'types' => [ + ['value' => HomepageAnnouncement::TYPE_ANNOUNCEMENT, 'label' => 'Announcement'], + ['value' => HomepageAnnouncement::TYPE_LAUNCH, 'label' => 'Launch'], + ['value' => HomepageAnnouncement::TYPE_NEWS, 'label' => 'News'], + ['value' => HomepageAnnouncement::TYPE_WORLD, 'label' => 'World'], + ['value' => HomepageAnnouncement::TYPE_EVENT, 'label' => 'Event'], + ['value' => HomepageAnnouncement::TYPE_NOTICE, 'label' => 'Notice'], + ['value' => HomepageAnnouncement::TYPE_MAINTENANCE, 'label' => 'Maintenance'], + ], + 'statuses' => [ + ['value' => HomepageAnnouncement::STATUS_DRAFT, 'label' => 'Draft'], + ['value' => HomepageAnnouncement::STATUS_PUBLISHED, 'label' => 'Published'], + ['value' => HomepageAnnouncement::STATUS_ARCHIVED, 'label' => 'Archived'], + ], + 'placements' => [ + ['value' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED, 'label' => 'Homepage after Featured Artwork'], + ], + 'linkTypes' => [ + ['value' => HomepageAnnouncement::LINK_TYPE_NONE, 'label' => 'None'], + ['value' => HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, 'label' => 'Custom URL'], + ['value' => HomepageAnnouncement::LINK_TYPE_NEWS, 'label' => 'News article'], + ['value' => HomepageAnnouncement::LINK_TYPE_WORLD, 'label' => 'World'], + ['value' => HomepageAnnouncement::LINK_TYPE_ARTWORK, 'label' => 'Artwork'], + ['value' => HomepageAnnouncement::LINK_TYPE_COLLECTION, 'label' => 'Collection'], + ['value' => HomepageAnnouncement::LINK_TYPE_GROUP, 'label' => 'Group'], + ['value' => HomepageAnnouncement::LINK_TYPE_PROFILE, 'label' => 'Profile'], + ['value' => HomepageAnnouncement::LINK_TYPE_EXPLORE, 'label' => 'Explore'], + ['value' => HomepageAnnouncement::LINK_TYPE_UPLOAD, 'label' => 'Upload'], + ], + 'gradients' => [ + ['value' => HomepageAnnouncement::GRADIENT_NOVA_AURORA, 'label' => 'Nova Aurora'], + ['value' => HomepageAnnouncement::GRADIENT_DEEP_SPACE, 'label' => 'Deep Space'], + ['value' => HomepageAnnouncement::GRADIENT_SUNRISE, 'label' => 'Sunrise'], + ['value' => HomepageAnnouncement::GRADIENT_OCEAN_GLOW, 'label' => 'Ocean Glow'], + ['value' => HomepageAnnouncement::GRADIENT_SPRING_VIBES, 'label' => 'Spring Vibes'], + ['value' => HomepageAnnouncement::GRADIENT_FANTASY_REALMS, 'label' => 'Fantasy Realms'], + ['value' => HomepageAnnouncement::GRADIENT_MINIMAL_LIGHT, 'label' => 'Minimal Light'], + ['value' => HomepageAnnouncement::GRADIENT_DARK_GLASS, 'label' => 'Dark Glass'], + ], + 'themes' => [ + ['value' => 'launch', 'label' => 'Launch'], + ['value' => 'announcement', 'label' => 'Announcement'], + ['value' => 'notice', 'label' => 'Notice'], + ['value' => 'maintenance', 'label' => 'Maintenance'], + ], + ]; + } + + private function currentActor(Request $request): object + { + return $request->user('controlpanel') ?? $request->user() ?? abort(403, 'Admin access required.'); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Settings/UpsertHomepageAnnouncementRequest.php b/app/Http/Requests/Settings/UpsertHomepageAnnouncementRequest.php new file mode 100644 index 00000000..ac18b944 --- /dev/null +++ b/app/Http/Requests/Settings/UpsertHomepageAnnouncementRequest.php @@ -0,0 +1,98 @@ +user('controlpanel') ?? $this->user()); + } + + protected function prepareForValidation(): void + { + $this->merge([ + 'is_active' => $this->boolean('is_active'), + 'is_dismissible' => $this->boolean('is_dismissible', true), + 'remove_background_image' => $this->boolean('remove_background_image'), + 'priority' => $this->filled('priority') ? (int) $this->input('priority') : 0, + 'dismiss_version' => $this->filled('dismiss_version') ? (int) $this->input('dismiss_version') : 1, + 'overlay_opacity' => $this->filled('overlay_opacity') ? (int) $this->input('overlay_opacity') : 55, + ]); + } + + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:180'], + 'subtitle' => ['nullable', 'string', 'max:255'], + 'badge_text' => ['nullable', 'string', 'max:100'], + 'content_html' => ['nullable', 'string'], + 'type' => ['required', 'string', Rule::in(HomepageAnnouncement::types())], + 'status' => ['required', 'string', Rule::in(HomepageAnnouncement::statuses())], + 'is_active' => ['required', 'boolean'], + 'starts_at' => ['nullable', 'date'], + 'ends_at' => ['nullable', 'date', 'after_or_equal:starts_at'], + 'priority' => ['required', 'integer', 'min:-9999', 'max:9999'], + 'is_dismissible' => ['required', 'boolean'], + 'dismiss_version' => ['required', 'integer', 'min:1', 'max:999999'], + 'gradient_preset' => ['nullable', 'string', Rule::in(HomepageAnnouncement::gradientPresets())], + 'theme_preset' => ['nullable', 'string', 'max:80'], + 'placement' => ['required', 'string', Rule::in(HomepageAnnouncement::placements())], + 'text_color' => ['nullable', 'string', 'max:32'], + 'overlay_opacity' => ['nullable', 'integer', 'min:0', 'max:100'], + 'background_image' => ['nullable', 'string', 'max:2048'], + 'background_image_file' => ['nullable', 'file', 'image', 'mimes:jpeg,jpg,png,webp', 'max:5120'], + 'remove_background_image' => ['nullable', 'boolean'], + + 'primary_link_label' => ['nullable', 'string', 'max:80'], + 'primary_link_type' => ['nullable', 'string', Rule::in(HomepageAnnouncement::linkTypes())], + 'primary_link_url' => ['nullable', 'string', 'max:2048'], + 'primary_link_target_id' => ['nullable', 'integer', 'min:1'], + + 'secondary_link_label' => ['nullable', 'string', 'max:80'], + 'secondary_link_type' => ['nullable', 'string', Rule::in(HomepageAnnouncement::linkTypes())], + 'secondary_link_url' => ['nullable', 'string', 'max:2048'], + 'secondary_link_target_id' => ['nullable', 'integer', 'min:1'], + ]; + } + + public function withValidator($validator): void + { + $validator->after(function ($validator): void { + $sanitizer = app(HomepageAnnouncementSanitizer::class); + + foreach (['primary', 'secondary'] as $prefix) { + $type = (string) ($this->input($prefix . '_link_type') ?: HomepageAnnouncement::LINK_TYPE_NONE); + $label = trim((string) $this->input($prefix . '_link_label', '')); + $url = trim((string) $this->input($prefix . '_link_url', '')); + $targetId = (int) $this->input($prefix . '_link_target_id', 0); + + if ($url !== '' && ! $sanitizer->isSafeCustomUrl($url)) { + $validator->errors()->add($prefix . '_link_url', 'Use a relative path starting with / or an https:// URL. Unsafe protocols are not allowed.'); + } + + if ($type !== HomepageAnnouncement::LINK_TYPE_NONE && $label === '') { + $validator->errors()->add($prefix . '_link_label', 'Provide a CTA label when this link is enabled.'); + } + + if ($type === HomepageAnnouncement::LINK_TYPE_CUSTOM_URL && $url === '') { + $validator->errors()->add($prefix . '_link_url', 'Provide a URL for custom links.'); + } + + if (! in_array($type, [HomepageAnnouncement::LINK_TYPE_NONE, HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, HomepageAnnouncement::LINK_TYPE_EXPLORE, HomepageAnnouncement::LINK_TYPE_UPLOAD], true) + && $url === '' + && $targetId < 1) { + $validator->errors()->add($prefix . '_link_target_id', 'Provide a target id or a fallback URL for this CTA type.'); + } + } + }); + } +} \ No newline at end of file diff --git a/app/Models/HomepageAnnouncement.php b/app/Models/HomepageAnnouncement.php new file mode 100644 index 00000000..ebc4ceb1 --- /dev/null +++ b/app/Models/HomepageAnnouncement.php @@ -0,0 +1,197 @@ + 'boolean', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + 'primary_link_target_id' => 'integer', + 'secondary_link_target_id' => 'integer', + 'overlay_opacity' => 'integer', + 'priority' => 'integer', + 'is_dismissible' => 'boolean', + 'dismiss_version' => 'integer', + ]; + + public static function statuses(): array + { + return [ + self::STATUS_DRAFT, + self::STATUS_PUBLISHED, + self::STATUS_ARCHIVED, + ]; + } + + public static function types(): array + { + return [ + self::TYPE_ANNOUNCEMENT, + self::TYPE_LAUNCH, + self::TYPE_NEWS, + self::TYPE_WORLD, + self::TYPE_EVENT, + self::TYPE_NOTICE, + self::TYPE_MAINTENANCE, + ]; + } + + public static function placements(): array + { + return [ + self::PLACEMENT_HOMEPAGE_AFTER_FEATURED, + ]; + } + + public static function linkTypes(): array + { + return [ + self::LINK_TYPE_NONE, + self::LINK_TYPE_CUSTOM_URL, + self::LINK_TYPE_NEWS, + self::LINK_TYPE_WORLD, + self::LINK_TYPE_ARTWORK, + self::LINK_TYPE_COLLECTION, + self::LINK_TYPE_GROUP, + self::LINK_TYPE_PROFILE, + self::LINK_TYPE_EXPLORE, + self::LINK_TYPE_UPLOAD, + ]; + } + + public static function gradientPresets(): array + { + return [ + self::GRADIENT_NOVA_AURORA, + self::GRADIENT_DEEP_SPACE, + self::GRADIENT_SUNRISE, + self::GRADIENT_OCEAN_GLOW, + self::GRADIENT_SPRING_VIBES, + self::GRADIENT_FANTASY_REALMS, + self::GRADIENT_MINIMAL_LIGHT, + self::GRADIENT_DARK_GLASS, + ]; + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', self::STATUS_PUBLISHED); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public function scopeVisibleNow(Builder $query, ?Carbon $now = null): Builder + { + $now ??= now(); + + return $query + ->where(function (Builder $builder) use ($now): void { + $builder->whereNull('starts_at') + ->orWhere('starts_at', '<=', $now); + }) + ->where(function (Builder $builder) use ($now): void { + $builder->whereNull('ends_at') + ->orWhere('ends_at', '>=', $now); + }); + } + + public function scopeForPlacement(Builder $query, string $placement): Builder + { + return $query->where('placement', $placement); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updatedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } +} \ No newline at end of file diff --git a/app/Observers/HomepageAnnouncementObserver.php b/app/Observers/HomepageAnnouncementObserver.php new file mode 100644 index 00000000..05fb1653 --- /dev/null +++ b/app/Observers/HomepageAnnouncementObserver.php @@ -0,0 +1,31 @@ +clearActiveCache(); + } + + public function deleted(HomepageAnnouncement $announcement): void + { + app(HomepageAnnouncementService::class)->clearActiveCache(); + } + + public function restored(HomepageAnnouncement $announcement): void + { + app(HomepageAnnouncementService::class)->clearActiveCache(); + } + + public function forceDeleted(HomepageAnnouncement $announcement): void + { + app(HomepageAnnouncementService::class)->clearActiveCache(); + } +} \ No newline at end of file diff --git a/app/Services/HomepageAnnouncementSanitizer.php b/app/Services/HomepageAnnouncementSanitizer.php new file mode 100644 index 00000000..e1a1e094 --- /dev/null +++ b/app/Services/HomepageAnnouncementSanitizer.php @@ -0,0 +1,175 @@ + ['href', 'title', 'target', 'rel'], + ]; + + public function sanitizeHtml(?string $html): string + { + if ($html === null || trim($html) === '') { + return ''; + } + + $encodedHtml = mb_encode_numericentity( + $html, + [0x80, 0x10FFFF, 0, 0xFFFFFF], + 'UTF-8' + ); + + $document = new DOMDocument('1.0', 'UTF-8'); + + libxml_use_internal_errors(true); + $document->loadHTML( + '' . $encodedHtml . '', + LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD + ); + libxml_clear_errors(); + + $body = $document->getElementsByTagName('body')->item(0); + if (! $body instanceof DOMNode) { + return ''; + } + + $this->cleanNode($body); + + $innerHtml = ''; + foreach ($body->childNodes as $child) { + $innerHtml .= $document->saveHTML($child); + } + + return trim(html_entity_decode($innerHtml, ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + + public function sanitizeCustomUrl(?string $url): ?string + { + $url = trim((string) $url); + + if ($url === '') { + return null; + } + + if (! $this->isSafeCustomUrl($url)) { + return null; + } + + return $url; + } + + public function isSafeCustomUrl(?string $url): bool + { + $url = trim((string) $url); + if ($url === '') { + return true; + } + + $lower = strtolower($url); + if (str_starts_with($lower, 'javascript:') || str_contains($lower, 'onerror=') || str_contains($lower, 'onclick=')) { + return false; + } + + if (str_starts_with($url, '/')) { + return true; + } + + return str_starts_with($lower, 'https://'); + } + + private function cleanNode(DOMNode $node): void + { + $toRemove = []; + $toUnwrap = []; + + foreach ($node->childNodes as $child) { + if ($child->nodeType !== XML_ELEMENT_NODE) { + continue; + } + + if (! $child instanceof DOMElement) { + continue; + } + + $tag = strtolower($child->nodeName); + + if (in_array($tag, ['script', 'style', 'iframe'], true)) { + $toRemove[] = $child; + continue; + } + + if (! in_array($tag, self::ALLOWED_TAGS, true)) { + $toUnwrap[] = $child; + continue; + } + + $allowedAttrs = self::ALLOWED_ATTRS[$tag] ?? []; + $attrsToRemove = []; + foreach ($child->attributes as $attribute) { + if (! in_array($attribute->nodeName, $allowedAttrs, true)) { + $attrsToRemove[] = $attribute->nodeName; + } + } + + foreach ($attrsToRemove as $attributeName) { + $child->removeAttribute($attributeName); + } + + if ($tag === 'a') { + $href = trim($child->getAttribute('href')); + if ($href === '' || ! $this->isSafeAnchorHref($href)) { + $toUnwrap[] = $child; + continue; + } + + if (str_starts_with(strtolower($href), 'https://')) { + $child->setAttribute('rel', 'noopener noreferrer'); + $child->setAttribute('target', '_blank'); + } else { + $child->removeAttribute('target'); + $child->removeAttribute('rel'); + } + } + + $this->cleanNode($child); + } + + foreach ($toRemove as $element) { + $node->removeChild($element); + } + + foreach ($toUnwrap as $element) { + while ($element->firstChild) { + $node->insertBefore($element->firstChild, $element); + } + + $node->removeChild($element); + } + } + + private function isSafeAnchorHref(string $href): bool + { + $lower = strtolower(trim($href)); + + if (str_starts_with($lower, 'javascript:') || str_starts_with($lower, 'data:')) { + return false; + } + + if (str_starts_with($href, '/') || str_starts_with($href, '#')) { + return true; + } + + return str_starts_with($lower, 'https://'); + } +} \ No newline at end of file diff --git a/app/Services/HomepageAnnouncementService.php b/app/Services/HomepageAnnouncementService.php new file mode 100644 index 00000000..4c49a19a --- /dev/null +++ b/app/Services/HomepageAnnouncementService.php @@ -0,0 +1,238 @@ +addMinutes(5), function (): ?HomepageAnnouncement { + return HomepageAnnouncement::query() + ->published() + ->active() + ->visibleNow() + ->forPlacement(HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED) + ->orderByDesc('priority') + ->orderByDesc('starts_at') + ->first(); + }); + } + + public function clearActiveCache(): void + { + Cache::forget(self::ACTIVE_CACHE_KEY); + } + + /** + * @param array $attributes + * @return array + */ + public function sanitizeAttributes(array $attributes): array + { + foreach (['primary_link_url', 'secondary_link_url'] as $key) { + $attributes[$key] = $this->sanitizer->sanitizeCustomUrl($attributes[$key] ?? null); + } + + $attributes['content_html'] = $this->sanitizer->sanitizeHtml($attributes['content_html'] ?? null); + + return $attributes; + } + + public function toHomepagePayload(?HomepageAnnouncement $announcement): ?array + { + if (! $announcement) { + return null; + } + + return [ + 'id' => (int) $announcement->id, + 'dismiss_version' => max(1, (int) $announcement->dismiss_version), + 'title' => (string) $announcement->title, + 'subtitle' => $announcement->subtitle, + 'badge_text' => $announcement->badge_text, + 'content_html' => $announcement->content_html, + 'gradient_preset' => $announcement->gradient_preset, + 'theme_preset' => $announcement->theme_preset, + 'background_image_url' => $this->resolveBackgroundImageUrl($announcement->background_image), + 'is_dismissible' => (bool) $announcement->is_dismissible, + 'overlay_opacity' => (int) ($announcement->overlay_opacity ?? 55), + 'primary_link' => $this->buildLinkPayload($announcement, 'primary'), + 'secondary_link' => $this->buildLinkPayload($announcement, 'secondary'), + ]; + } + + /** + * @param array $attributes + */ + public function previewPayload(array $attributes): ?array + { + $announcement = new HomepageAnnouncement(); + $announcement->forceFill($this->sanitizeAttributes($attributes)); + $announcement->id = 0; + + return $this->toHomepagePayload($announcement); + } + + private function buildLinkPayload(HomepageAnnouncement $announcement, string $prefix): ?array + { + $label = trim((string) $announcement->getAttribute($prefix . '_link_label')); + if ($label === '') { + return null; + } + + $url = $this->resolveLinkUrl($announcement, $prefix); + if ($url === null) { + return null; + } + + return [ + 'label' => $label, + 'url' => $url, + ]; + } + + private function resolveLinkUrl(HomepageAnnouncement $announcement, string $prefix): ?string + { + $type = (string) ($announcement->getAttribute($prefix . '_link_type') ?? HomepageAnnouncement::LINK_TYPE_NONE); + $url = $this->sanitizer->sanitizeCustomUrl($announcement->getAttribute($prefix . '_link_url')); + $targetId = (int) ($announcement->getAttribute($prefix . '_link_target_id') ?? 0); + + return match ($type) { + HomepageAnnouncement::LINK_TYPE_NONE => null, + HomepageAnnouncement::LINK_TYPE_CUSTOM_URL => $url, + HomepageAnnouncement::LINK_TYPE_EXPLORE => Route::has('explore.index') ? route('explore.index') : ($url ?? '/explore'), + HomepageAnnouncement::LINK_TYPE_UPLOAD => Route::has('upload') ? route('upload') : ($url ?? '/upload'), + HomepageAnnouncement::LINK_TYPE_NEWS => $this->newsUrl($targetId) ?? $url, + HomepageAnnouncement::LINK_TYPE_WORLD => $this->worldUrl($targetId) ?? $url, + HomepageAnnouncement::LINK_TYPE_COLLECTION => $this->collectionUrl($targetId) ?? $url, + HomepageAnnouncement::LINK_TYPE_GROUP => $this->groupUrl($targetId) ?? $url, + HomepageAnnouncement::LINK_TYPE_PROFILE => $this->profileUrl($targetId) ?? $url, + HomepageAnnouncement::LINK_TYPE_ARTWORK => $this->artworkUrl($targetId) ?? $url, + default => $url, + }; + } + + private function resolveBackgroundImageUrl(?string $backgroundImage): ?string + { + $backgroundImage = trim((string) $backgroundImage); + if ($backgroundImage === '') { + return null; + } + + if (str_starts_with($backgroundImage, 'http://') || str_starts_with($backgroundImage, 'https://') || str_starts_with($backgroundImage, '/')) { + return $backgroundImage; + } + + return Storage::disk('public')->url($backgroundImage); + } + + private function artworkUrl(int $artworkId): ?string + { + if ($artworkId < 1) { + return null; + } + + $artwork = Artwork::query()->find($artworkId); + if (! $artwork) { + return null; + } + + return null; + } + + private function newsUrl(int $articleId): ?string + { + if ($articleId < 1) { + return null; + } + + $article = NewsArticle::query()->find($articleId); + if (! $article) { + return null; + } + + return route('news.show', ['slug' => $article->slug]); + } + + private function worldUrl(int $worldId): ?string + { + if ($worldId < 1) { + return null; + } + + $world = World::query()->find($worldId); + if (! $world) { + return null; + } + + if (method_exists($world, 'publicUrl')) { + return $world->publicUrl(); + } + + return route('worlds.show', ['world' => $world->slug]); + } + + private function collectionUrl(int $collectionId): ?string + { + if ($collectionId < 1) { + return null; + } + + $collection = Collection::query()->with('user')->find($collectionId); + if (! $collection || ! $collection->user?->username) { + return null; + } + + return route('profile.collections.show', [ + 'username' => strtolower((string) $collection->user->username), + 'slug' => $collection->slug, + ]); + } + + private function groupUrl(int $groupId): ?string + { + if ($groupId < 1) { + return null; + } + + $group = Group::query()->find($groupId); + if (! $group) { + return null; + } + + return route('groups.show', ['group' => $group]); + } + + private function profileUrl(int $userId): ?string + { + if ($userId < 1) { + return null; + } + + $user = User::query()->find($userId); + if (! $user?->username) { + return null; + } + + return route('profile.show', ['username' => strtolower((string) $user->username)]); + } +} \ No newline at end of file diff --git a/app/Services/HomepageService.php b/app/Services/HomepageService.php index c0def2bd..bcc46577 100644 --- a/app/Services/HomepageService.php +++ b/app/Services/HomepageService.php @@ -7,6 +7,7 @@ namespace App\Services; use App\Models\Artwork; use App\Models\Leaderboard; use App\Models\Tag; +use App\Services\HomepageAnnouncementService; use App\Services\ArtworkSearchService; use App\Services\EarlyGrowth\EarlyGrowth; use App\Services\EarlyGrowth\GridFiller; @@ -57,6 +58,7 @@ final class HomepageService private readonly GroupDiscoveryService $groupDiscovery, private readonly LeaderboardService $leaderboards, private readonly WorldService $worlds, + private readonly HomepageAnnouncementService $homepageAnnouncements, ) {} // ───────────────────────────────────────────────────────────────────────── @@ -68,9 +70,17 @@ final class HomepageService */ public function all(): array { - return $this->guestPayloadCache()->remember( + // Use a stale-while-revalidate pattern: serve fresh cache for a short + // period and allow serving stale data while the cache is recalculated + // in the background. This reduces latency for the homepage on cache + // miss/expiration and keeps the heavy aggregation from blocking + // responses. + $ttl = $this->guestPayloadCacheTtl(); + $freshSeconds = min(30, max(5, (int) config('homepage.fresh_seconds', 30))); + + return $this->guestPayloadCache()->flexible( $this->guestPayloadCacheKey(), - $this->guestPayloadCacheTtl(), + [$freshSeconds, $ttl], fn (): array => $this->buildGuestPayload(), ); } @@ -119,6 +129,7 @@ final class HomepageService { return [ 'hero' => $this->getHeroArtwork(), + 'announcement' => $this->homepageAnnouncements->toHomepagePayload($this->homepageAnnouncements->getActiveForHomepage()), 'community_favorites' => $this->getCommunityFavorites(), 'hall_of_fame' => $this->getHallOfFame(), 'rising' => $this->getRising(), @@ -171,6 +182,7 @@ final class HomepageService 'is_logged_in' => true, 'user_data' => $this->getUserData($user), 'hero' => $this->getHeroArtwork(), + 'announcement' => $this->homepageAnnouncements->toHomepagePayload($this->homepageAnnouncements->getActiveForHomepage()), 'community_favorites' => $this->getCommunityFavorites(), 'hall_of_fame' => $this->getHallOfFame(), 'for_you' => $this->getForYouPreview($user), diff --git a/database/migrations/2026_05_01_120000_create_homepage_announcements_table.php b/database/migrations/2026_05_01_120000_create_homepage_announcements_table.php new file mode 100644 index 00000000..430ef329 --- /dev/null +++ b/database/migrations/2026_05_01_120000_create_homepage_announcements_table.php @@ -0,0 +1,71 @@ +id(); + $table->string('title', 180); + $table->string('subtitle', 255)->nullable(); + $table->string('badge_text', 100)->nullable(); + $table->longText('content_html')->nullable(); + + $table->string('type', 40)->default('announcement'); + $table->string('status', 40)->default('draft'); + $table->boolean('is_active')->default(true); + + $table->dateTime('starts_at')->nullable(); + $table->dateTime('ends_at')->nullable(); + + $table->string('primary_link_label', 80)->nullable(); + $table->string('primary_link_type', 40)->nullable(); + $table->string('primary_link_url', 2048)->nullable(); + $table->string('primary_link_target_type', 40)->nullable(); + $table->unsignedBigInteger('primary_link_target_id')->nullable(); + + $table->string('secondary_link_label', 80)->nullable(); + $table->string('secondary_link_type', 40)->nullable(); + $table->string('secondary_link_url', 2048)->nullable(); + $table->string('secondary_link_target_type', 40)->nullable(); + $table->unsignedBigInteger('secondary_link_target_id')->nullable(); + + $table->string('background_type', 40)->nullable(); + $table->string('background_image', 2048)->nullable(); + $table->string('gradient_preset', 80)->nullable(); + $table->string('theme_preset', 80)->nullable(); + $table->string('text_color', 32)->nullable(); + $table->unsignedTinyInteger('overlay_opacity')->nullable(); + + $table->string('placement', 80)->default('homepage_after_featured'); + $table->integer('priority')->default(0); + $table->boolean('is_dismissible')->default(true); + $table->unsignedInteger('dismiss_version')->default(1); + + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestamps(); + $table->softDeletes(); + + $table->index('status'); + $table->index('is_active'); + $table->index('starts_at'); + $table->index('ends_at'); + $table->index('placement'); + $table->index('priority'); + $table->index(['placement', 'status', 'is_active', 'priority'], 'homepage_announcements_surface_idx'); + }); + } + + public function down(): void + { + Schema::dropIfExists('homepage_announcements'); + } +}; \ No newline at end of file diff --git a/database/seeders/HomepageAnnouncementLaunchSeeder.php b/database/seeders/HomepageAnnouncementLaunchSeeder.php new file mode 100644 index 00000000..87b83a47 --- /dev/null +++ b/database/seeders/HomepageAnnouncementLaunchSeeder.php @@ -0,0 +1,59 @@ +firstWhere('slug', 'skinbase-nova-is-live'); + + HomepageAnnouncement::query()->updateOrCreate( + [ + 'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED, + 'type' => HomepageAnnouncement::TYPE_LAUNCH, + 'title' => 'Skinbase Nova is live.', + ], + [ + 'subtitle' => 'A new chapter for the Skinbase creative community.', + 'badge_text' => 'Launch Day · 1 May 2026', + 'content_html' => implode("\n", [ + '

Today, 1 May 2026, Skinbase begins a new chapter.

', + '

Skinbase Nova is a modern reboot of our creative community for digital art, wallpapers, skins, photography, customization, and discovery.

', + '

We are bringing the spirit of classic Skinbase into a faster, cleaner, and more modern experience — built for creators, fans, and the future.

', + '

Welcome to Skinbase Nova.

', + ]), + 'status' => HomepageAnnouncement::STATUS_PUBLISHED, + 'is_active' => true, + 'starts_at' => Carbon::create(2026, 5, 1, 0, 0, 0), + 'ends_at' => null, + 'primary_link_label' => 'Explore Nova', + 'primary_link_type' => HomepageAnnouncement::LINK_TYPE_EXPLORE, + 'primary_link_url' => '/explore', + 'primary_link_target_type' => null, + 'primary_link_target_id' => null, + 'secondary_link_label' => $launchStory ? 'Read the launch story' : null, + 'secondary_link_type' => $launchStory ? HomepageAnnouncement::LINK_TYPE_NEWS : HomepageAnnouncement::LINK_TYPE_NONE, + 'secondary_link_url' => $launchStory ? null : null, + 'secondary_link_target_type' => $launchStory ? HomepageAnnouncement::LINK_TYPE_NEWS : null, + 'secondary_link_target_id' => $launchStory?->id, + 'background_type' => null, + 'background_image' => null, + 'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA, + 'theme_preset' => 'launch', + 'text_color' => null, + 'overlay_opacity' => 55, + 'priority' => 100, + 'is_dismissible' => true, + 'dismiss_version' => 1, + ] + ); + } +} \ No newline at end of file diff --git a/resources/js/Pages/Admin/HomepageAnnouncements/Form.jsx b/resources/js/Pages/Admin/HomepageAnnouncements/Form.jsx new file mode 100644 index 00000000..26c63f82 --- /dev/null +++ b/resources/js/Pages/Admin/HomepageAnnouncements/Form.jsx @@ -0,0 +1,667 @@ +import React from 'react' +import { Head, Link, useForm, usePage } from '@inertiajs/react' +import AdminLayout from '../../../Layouts/AdminLayout' +import HomepageAnnouncement from '../../../components/homepage/HomepageAnnouncement' +import HomepageAnnouncementEditor from '../../../components/homepage/HomepageAnnouncementEditor' +import Checkbox from '../../../components/ui/Checkbox' +import DateTimePicker from '../../../components/ui/DateTimePicker' +import NovaSelect from '../../../components/ui/NovaSelect' +import ShareToast from '../../../components/ui/ShareToast' + +const BACKGROUND_IMAGE_ACCEPT = 'image/jpeg,image/jpg,image/png,image/webp' +const BACKGROUND_IMAGE_MAX_BYTES = 5 * 1024 * 1024 + +const FORM_TABS = [ + { id: 'overview', label: 'Overview', description: 'Identity, status, and schedule.' }, + { id: 'content', label: 'Content', description: 'Message body and CTA links.' }, + { id: 'design', label: 'Design', description: 'Visual treatment and background media.' }, + { id: 'behavior', label: 'Behavior', description: 'Dismiss rules and placement.' }, +] + +const FIELD_TAB_MAP = { + title: 'overview', + badge_text: 'overview', + subtitle: 'overview', + type: 'overview', + status: 'overview', + priority: 'overview', + is_active: 'overview', + starts_at: 'overview', + ends_at: 'overview', + content_html: 'content', + primary_link_label: 'content', + primary_link_type: 'content', + primary_link_url: 'content', + primary_link_target_id: 'content', + secondary_link_label: 'content', + secondary_link_type: 'content', + secondary_link_url: 'content', + secondary_link_target_id: 'content', + gradient_preset: 'design', + theme_preset: 'design', + overlay_opacity: 'design', + background_image: 'design', + background_image_file: 'design', + remove_background_image: 'design', + dismiss_version: 'behavior', + placement: 'behavior', + is_dismissible: 'behavior', +} + +function isIntegerLike(value) { + if (typeof value === 'number') return Number.isInteger(value) + if (typeof value !== 'string') return false + const normalized = value.trim() + return normalized !== '' && /^-?\d+$/.test(normalized) +} + +function isSafeClientUrl(value) { + const normalized = String(value || '').trim() + if (!normalized) return true + return normalized.startsWith('/') || normalized.startsWith('https://') +} + +function firstErrorMessage(errors) { + const firstKey = Object.keys(errors || {})[0] + if (!firstKey) return null + const value = errors[firstKey] + return Array.isArray(value) ? value[0] : value +} + +function getCsrfToken() { + if (typeof document === 'undefined') return '' + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' +} + +function FieldError({ error }) { + if (!error) return null + return

{error}

+} + +function resolveTabFromErrors(errors) { + const firstKey = Object.keys(errors || {})[0] + return FIELD_TAB_MAP[firstKey] || 'overview' +} + +function Section({ title, description, children }) { + return ( +
+
+

{title}

+ {description ?

{description}

: null} +
+
{children}
+
+ ) +} + +function TextField({ label, value, onChange, error, ...rest }) { + return ( + + ) +} + +function ToggleField({ label, checked, onChange, help }) { + return ( +
+
+
{label}
+ {help ?
{help}
: null} +
+
+ +
+
+ ) +} + +function SelectField({ label, value, onChange, options, error }) { + return ( + + ) +} + +function DateTimeField({ label, value, onChange, error }) { + return ( + + ) +} + +function BackgroundImageDropzone({ previewUrl, storedValue, selectedFileName, error, onSelect }) { + const inputRef = React.useRef(null) + const [dragging, setDragging] = React.useState(false) + + const handleFile = React.useCallback((file) => { + onSelect?.(file || null) + }, [onSelect]) + + return ( +
+
+ Upload background image + +
+ +
inputRef.current?.click()} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + inputRef.current?.click() + } + }} + onDragOver={(event) => { + event.preventDefault() + setDragging(true) + }} + onDragEnter={(event) => { + event.preventDefault() + setDragging(true) + }} + onDragLeave={(event) => { + event.preventDefault() + setDragging(false) + }} + onDrop={(event) => { + event.preventDefault() + setDragging(false) + handleFile(event.dataTransfer?.files?.[0] || null) + }} + className={[ + 'rounded-[28px] border border-dashed px-5 py-5 transition outline-none', + dragging + ? 'border-sky-300/50 bg-sky-400/12' + : 'border-white/10 bg-black/20 hover:border-white/20 hover:bg-white/[0.04]', + ].join(' ')} + > +
+
+
+ +
+
+
Drop image here or browse
+
JPG, PNG, or WEBP. Maximum 5 MB. The selected image is previewed here and on the card preview.
+
+ JPG + PNG + WEBP + Max 5 MB +
+
+
+ +
+ {previewUrl ? ( + Background preview + ) : ( +
No background image selected
+ )} +
+
+ + {selectedFileName ?
Selected file: {selectedFileName}
: null} + {!selectedFileName && storedValue ?
Stored path: {storedValue}
: null} + {error ?
{error}
: null} + + { + handleFile(event.target.files?.[0] || null) + event.target.value = '' + }} + /> +
+
+ ) +} + +function LinkFields({ title, prefix, form, options }) { + return ( +
+

{title}

+
+ form.setData(`${prefix}_link_label`, event.target.value)} error={form.errors[`${prefix}_link_label`]} maxLength={80} /> + form.setData(`${prefix}_link_type`, nextValue)} options={options.linkTypes} error={form.errors[`${prefix}_link_type`]} /> +
+
+ form.setData(`${prefix}_link_url`, event.target.value)} error={form.errors[`${prefix}_link_url`]} placeholder="/explore or https://example.com" maxLength={2048} /> + form.setData(`${prefix}_link_target_id`, event.target.value)} error={form.errors[`${prefix}_link_target_id`]} placeholder="Optional entity id" inputMode="numeric" /> +
+
+ ) +} + +export default function HomepageAnnouncementForm({ announcement, previewAnnouncement, options, submitUrl, previewUrl, indexUrl, destroyUrl }) { + const { props } = usePage() + const isEditing = Boolean(announcement?.id) + const flash = props.flash ?? {} + const [activeTab, setActiveTab] = React.useState('overview') + const [preview, setPreview] = React.useState(previewAnnouncement || null) + const [previewBusy, setPreviewBusy] = React.useState(false) + const [previewError, setPreviewError] = React.useState('') + const [backgroundImageError, setBackgroundImageError] = React.useState('') + const [backgroundPreviewUrl, setBackgroundPreviewUrl] = React.useState(announcement?.background_image_url || '') + const [toast, setToast] = React.useState({ id: 0, visible: false, message: '', variant: 'success' }) + + const form = useForm({ + ...announcement, + background_image_file: null, + }) + + const showToast = React.useCallback((message, variant = 'success') => { + setToast({ + id: Date.now(), + visible: true, + message, + variant, + }) + }, []) + + React.useEffect(() => { + return () => { + if (backgroundPreviewUrl?.startsWith?.('blob:')) { + URL.revokeObjectURL(backgroundPreviewUrl) + } + } + }, [backgroundPreviewUrl]) + + const previewWithLocalImage = React.useMemo(() => { + if (!preview) return null + if (!backgroundPreviewUrl) return preview + return { ...preview, background_image_url: backgroundPreviewUrl } + }, [backgroundPreviewUrl, preview]) + + const validateForm = React.useCallback((statusOverride = null) => { + const data = { ...form.data, status: statusOverride || form.data.status } + const errors = [] + + if (!String(data.title || '').trim()) { + errors.push('Title is required.') + } + + if (!String(data.type || '').trim()) { + errors.push('Type is required.') + } + + if (!String(data.status || '').trim()) { + errors.push('Status is required.') + } + + if (!String(data.placement || '').trim()) { + errors.push('Placement is required.') + } + + if (!isIntegerLike(data.priority)) { + errors.push('Priority must be a whole number.') + } + + if (!isIntegerLike(data.dismiss_version) || Number(data.dismiss_version) < 1) { + errors.push('Dismiss version must be a whole number greater than or equal to 1.') + } + + if (String(data.overlay_opacity || '').trim() !== '' && (!isIntegerLike(data.overlay_opacity) || Number(data.overlay_opacity) < 0 || Number(data.overlay_opacity) > 100)) { + errors.push('Overlay opacity must be a whole number between 0 and 100.') + } + + if (data.starts_at && Number.isNaN(Date.parse(data.starts_at))) { + errors.push('Starts at must be a valid date and time.') + } + + if (data.ends_at && Number.isNaN(Date.parse(data.ends_at))) { + errors.push('Ends at must be a valid date and time.') + } + + if (data.starts_at && data.ends_at && Date.parse(data.ends_at) < Date.parse(data.starts_at)) { + errors.push('Ends at must be after or equal to starts at.') + } + + for (const prefix of ['primary', 'secondary']) { + const type = String(data[`${prefix}_link_type`] || 'none') + const label = String(data[`${prefix}_link_label`] || '').trim() + const url = String(data[`${prefix}_link_url`] || '').trim() + const targetId = data[`${prefix}_link_target_id`] + + if (type !== 'none' && !label) { + errors.push(`${prefix === 'primary' ? 'Primary' : 'Secondary'} CTA label is required when that link is enabled.`) + } + + if (url && !isSafeClientUrl(url)) { + errors.push(`${prefix === 'primary' ? 'Primary' : 'Secondary'} CTA URL must start with / or https://.`) + } + + if (type === 'custom_url' && !url) { + errors.push(`${prefix === 'primary' ? 'Primary' : 'Secondary'} custom CTA requires a URL.`) + } + + if (!['none', 'custom_url', 'explore', 'upload'].includes(type) && !url) { + if (String(targetId || '').trim() === '') { + errors.push(`${prefix === 'primary' ? 'Primary' : 'Secondary'} CTA requires a target id or fallback URL.`) + } else if (!isIntegerLike(targetId) || Number(targetId) < 1) { + errors.push(`${prefix === 'primary' ? 'Primary' : 'Secondary'} target id must be a whole number greater than or equal to 1.`) + } + } + } + + return errors + }, [form.data]) + + const applyBackgroundFile = React.useCallback((file) => { + setBackgroundImageError('') + + if (!file) { + form.setData('background_image_file', null) + return + } + + const fileType = String(file.type || '').toLowerCase() + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'] + + if (!allowedTypes.includes(fileType)) { + setBackgroundImageError('Use a JPG, PNG, or WEBP image for the announcement background.') + return + } + + if (file.size > BACKGROUND_IMAGE_MAX_BYTES) { + setBackgroundImageError('Background images must be 5 MB or smaller.') + return + } + + form.setData('background_image_file', file) + form.setData('remove_background_image', false) + + if (backgroundPreviewUrl?.startsWith?.('blob:')) { + URL.revokeObjectURL(backgroundPreviewUrl) + } + + setBackgroundPreviewUrl(URL.createObjectURL(file)) + }, [backgroundPreviewUrl, form]) + + const submit = (statusOverride = null) => { + const validationErrors = validateForm(statusOverride) + if (validationErrors.length > 0) { + showToast(validationErrors[0], 'error') + return + } + + form.transform((data) => { + const payload = { ...data, status: statusOverride || data.status } + if (isEditing) payload._method = 'patch' + return payload + }) + + form.post(submitUrl, { + forceFormData: true, + preserveScroll: true, + onError: (errors) => { + setActiveTab(resolveTabFromErrors(errors)) + const message = firstErrorMessage(errors) || 'Please correct the form and try again.' + showToast(message, 'error') + }, + onSuccess: () => { + showToast(isEditing ? 'Homepage announcement updated.' : 'Homepage announcement created.', 'success') + }, + onFinish: () => form.transform((data) => data), + }) + } + + const runPreview = async () => { + const validationErrors = validateForm() + if (validationErrors.length > 0) { + const message = validationErrors[0] + setPreviewError(message) + showToast(message, 'error') + return + } + + setPreviewBusy(true) + setPreviewError('') + + try { + const formData = new FormData() + const payload = form.data + + Object.entries(payload).forEach(([key, value]) => { + if (value === null || value === undefined || key === 'id') return + if (value instanceof File) { + formData.append(key, value) + return + } + formData.append(key, String(value)) + }) + + const response = await fetch(previewUrl, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'X-CSRF-TOKEN': getCsrfToken(), + 'X-Requested-With': 'XMLHttpRequest', + Accept: 'application/json', + }, + body: formData, + }) + + const body = await response.json().catch(() => ({})) + if (!response.ok) { + throw new Error(body?.message || 'Preview failed.') + } + + setPreview(body.announcement || null) + } catch (error) { + const message = error.message || 'Preview failed.' + setPreviewError(message) + showToast(message, 'error') + } finally { + setPreviewBusy(false) + } + } + + return ( + + + setToast((current) => ({ ...current, visible: false }))} + /> + + {flash.success ?
{flash.success}
: null} + {flash.error ?
{flash.error}
: null} + +
+
+ + + Back to announcements + +
+ + + + {destroyUrl ? ( + + ) : null} +
+
+
+ +
+
+
+
+ {FORM_TABS.map((tab) => ( + + ))} +
+

{FORM_TABS.find((tab) => tab.id === activeTab)?.description}

+
+ + {activeTab === 'overview' ? ( + <> +
+
+ form.setData('title', event.target.value)} error={form.errors.title} maxLength={180} /> + form.setData('badge_text', event.target.value)} error={form.errors.badge_text} maxLength={100} /> +
+ form.setData('subtitle', event.target.value)} error={form.errors.subtitle} maxLength={255} /> +
+ form.setData('type', nextValue)} options={options.types} error={form.errors.type} /> + form.setData('status', nextValue)} options={options.statuses} error={form.errors.status} /> + form.setData('priority', event.target.value)} error={form.errors.priority} inputMode="numeric" /> +
+ form.setData('is_active', event.target.checked)} help="Inactive announcements never surface even when published." /> +
+ +
+
+ form.setData('starts_at', nextValue)} error={form.errors.starts_at} /> + form.setData('ends_at', nextValue)} error={form.errors.ends_at} /> +
+
+ + ) : null} + + {activeTab === 'content' ? ( + <> +
+
+ Announcement message + form.setData('content_html', nextValue)} + placeholder="Write the launch message with headings, lists, links, quotes, and highlighted copy." + error={form.errors.content_html} + minHeight={14} + /> +
+ Supported formatting matches the homepage sanitizer: paragraphs, bold, italic, links, lists, H2, H3, and blockquotes. +
+
+
+ +
+ + +
+ + ) : null} + + {activeTab === 'design' ? ( +
+
+ form.setData('gradient_preset', nextValue)} options={options.gradients} error={form.errors.gradient_preset} /> + form.setData('theme_preset', nextValue)} options={options.themes} error={form.errors.theme_preset} /> +
+
+ form.setData('overlay_opacity', event.target.value)} error={form.errors.overlay_opacity} inputMode="numeric" /> +
+
+ form.setData('background_image', event.target.value)} error={form.errors.background_image} placeholder="/storage/homepage-announcements/... or https://..." /> + + { + form.setData('remove_background_image', event.target.checked) + if (event.target.checked) { + setBackgroundImageError('') + form.setData('background_image_file', null) + if (backgroundPreviewUrl?.startsWith?.('blob:')) { + URL.revokeObjectURL(backgroundPreviewUrl) + } + setBackgroundPreviewUrl('') + } + }} help="Turn this on to clear the saved background image on the next save." /> +
+ ) : null} + + {activeTab === 'behavior' ? ( +
+
+ form.setData('dismiss_version', event.target.value)} error={form.errors.dismiss_version} inputMode="numeric" /> + form.setData('placement', nextValue)} options={options.placements} error={form.errors.placement} /> +
+ form.setData('is_dismissible', event.target.checked)} help="When disabled, the card remains visible and no restore pill is shown." /> +
+ ) : null} +
+ + +
+
+ ) +} \ No newline at end of file diff --git a/resources/js/Pages/Admin/HomepageAnnouncements/Index.jsx b/resources/js/Pages/Admin/HomepageAnnouncements/Index.jsx new file mode 100644 index 00000000..7716861b --- /dev/null +++ b/resources/js/Pages/Admin/HomepageAnnouncements/Index.jsx @@ -0,0 +1,110 @@ +import React from 'react' +import { Head, Link, router, usePage } from '@inertiajs/react' +import AdminLayout from '../../../Layouts/AdminLayout' +import HomepageAnnouncement from '../../../components/homepage/HomepageAnnouncement' + +function formatDateRange(startsAt, endsAt) { + const formatter = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' }) + const start = startsAt ? formatter.format(new Date(startsAt)) : 'Now' + const end = endsAt ? formatter.format(new Date(endsAt)) : 'Open ended' + return `${start} → ${end}` +} + +function StatusBadge({ status, active }) { + const tone = status === 'published' + ? 'border-emerald-300/20 bg-emerald-300/10 text-emerald-100' + : status === 'archived' + ? 'border-amber-300/20 bg-amber-300/10 text-amber-100' + : 'border-slate-300/15 bg-slate-300/10 text-slate-200' + + return ( + + + {status} + + ) +} + +export default function HomepageAnnouncementsIndex({ announcements, createUrl }) { + const { props } = usePage() + const flash = props.flash ?? {} + + return ( + + + + {flash.success ?
{flash.success}
: null} + {flash.error ?
{flash.error}
: null} + +
+
+ Only the highest-priority published announcement that is active and inside its visibility window appears on the homepage. +
+ + Create announcement + +
+ +
+ {(announcements?.data || []).length === 0 ? ( +
+ No homepage announcements exist yet. +
+ ) : (announcements.data.map((announcement) => ( +
+
+
+
+ + {announcement.type} + Priority {announcement.priority} + Dismiss v{announcement.dismiss_version} +
+

{announcement.title}

+

{formatDateRange(announcement.starts_at, announcement.ends_at)}

+

{announcement.placement.replaceAll('_', ' ')}

+
+ +
+ Edit + +
+
+ +
+ +
+
+ ))) } +
+ + {announcements?.last_page > 1 ? ( +
+

Showing {announcements.from}–{announcements.to} of {announcements.total} announcements

+
+ {announcements.links.map((link, index) => ( + link.url ? ( +
+
+ ) : null} +
+ ) +} \ No newline at end of file diff --git a/resources/js/components/homepage/HomepageAnnouncement.jsx b/resources/js/components/homepage/HomepageAnnouncement.jsx new file mode 100644 index 00000000..f8e367bb --- /dev/null +++ b/resources/js/components/homepage/HomepageAnnouncement.jsx @@ -0,0 +1,241 @@ +import React from 'react' + +export const HOMEPAGE_ANNOUNCEMENT_STORAGE_KEY = 'skinbase:hidden_homepage_announcements' + +const PRESETS = { + nova_aurora: { + shell: 'border-cyan-300/15 bg-[radial-gradient(circle_at_top_left,rgba(34,211,238,0.26),transparent_30%),radial-gradient(circle_at_80%_20%,rgba(168,85,247,0.22),transparent_28%),linear-gradient(135deg,rgba(6,12,24,0.96),rgba(10,17,34,0.9))] text-white', + glow: 'from-cyan-400/25 via-fuchsia-400/10 to-transparent', + badge: 'border-cyan-200/20 bg-cyan-300/10 text-cyan-100', + primary: 'border-cyan-300/30 bg-cyan-300/15 text-cyan-50 hover:bg-cyan-300/22', + secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]', + prose: 'prose-invert prose-a:text-cyan-200 prose-strong:text-white', + }, + deep_space: { + shell: 'border-indigo-300/15 bg-[radial-gradient(circle_at_top,rgba(59,130,246,0.18),transparent_34%),linear-gradient(145deg,rgba(5,10,20,0.98),rgba(11,18,36,0.94))] text-white', + glow: 'from-indigo-400/20 via-sky-400/12 to-transparent', + badge: 'border-indigo-200/20 bg-indigo-300/10 text-indigo-100', + primary: 'border-indigo-300/30 bg-indigo-300/15 text-indigo-50 hover:bg-indigo-300/22', + secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]', + prose: 'prose-invert prose-a:text-indigo-200 prose-strong:text-white', + }, + sunrise: { + shell: 'border-amber-300/20 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.28),transparent_28%),linear-gradient(145deg,rgba(44,15,18,0.95),rgba(17,9,24,0.96))] text-white', + glow: 'from-amber-300/25 via-rose-300/10 to-transparent', + badge: 'border-amber-100/20 bg-amber-300/15 text-amber-50', + primary: 'border-amber-300/35 bg-amber-300/18 text-amber-50 hover:bg-amber-300/24', + secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]', + prose: 'prose-invert prose-a:text-amber-100 prose-strong:text-white', + }, + ocean_glow: { + shell: 'border-sky-300/15 bg-[radial-gradient(circle_at_top_right,rgba(56,189,248,0.24),transparent_28%),linear-gradient(135deg,rgba(3,20,38,0.98),rgba(8,27,45,0.92))] text-white', + glow: 'from-sky-400/24 via-emerald-300/10 to-transparent', + badge: 'border-sky-200/20 bg-sky-300/10 text-sky-100', + primary: 'border-sky-300/30 bg-sky-300/15 text-sky-50 hover:bg-sky-300/22', + secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]', + prose: 'prose-invert prose-a:text-sky-200 prose-strong:text-white', + }, + spring_vibes: { + shell: 'border-emerald-300/18 bg-[radial-gradient(circle_at_top_left,rgba(74,222,128,0.2),transparent_28%),linear-gradient(145deg,rgba(8,24,22,0.98),rgba(14,28,38,0.92))] text-white', + glow: 'from-emerald-300/24 via-lime-200/8 to-transparent', + badge: 'border-emerald-200/20 bg-emerald-300/10 text-emerald-100', + primary: 'border-emerald-300/30 bg-emerald-300/15 text-emerald-50 hover:bg-emerald-300/22', + secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]', + prose: 'prose-invert prose-a:text-emerald-200 prose-strong:text-white', + }, + fantasy_realms: { + shell: 'border-fuchsia-300/18 bg-[radial-gradient(circle_at_15%_10%,rgba(232,121,249,0.2),transparent_26%),linear-gradient(145deg,rgba(23,8,35,0.97),rgba(12,16,36,0.93))] text-white', + glow: 'from-fuchsia-300/24 via-violet-300/10 to-transparent', + badge: 'border-fuchsia-200/20 bg-fuchsia-300/10 text-fuchsia-100', + primary: 'border-fuchsia-300/30 bg-fuchsia-300/15 text-fuchsia-50 hover:bg-fuchsia-300/22', + secondary: 'border-white/12 bg-white/[0.05] text-white/85 hover:bg-white/[0.1]', + prose: 'prose-invert prose-a:text-fuchsia-200 prose-strong:text-white', + }, + minimal_light: { + shell: 'border-slate-300/35 bg-[linear-gradient(140deg,rgba(255,255,255,0.96),rgba(240,247,255,0.92))] text-slate-900', + glow: 'from-sky-200/50 via-transparent to-transparent', + badge: 'border-slate-300/60 bg-white/70 text-slate-700', + primary: 'border-slate-900/10 bg-slate-900 text-white hover:bg-slate-800', + secondary: 'border-slate-300/60 bg-white text-slate-700 hover:bg-slate-50', + prose: 'prose prose-slate prose-a:text-sky-700 prose-strong:text-slate-900', + }, + dark_glass: { + shell: 'border-white/12 bg-[linear-gradient(145deg,rgba(12,16,24,0.82),rgba(6,10,18,0.76))] text-white backdrop-blur-xl', + glow: 'from-white/10 via-white/0 to-transparent', + badge: 'border-white/12 bg-white/[0.06] text-white/90', + primary: 'border-white/18 bg-white/[0.09] text-white hover:bg-white/[0.14]', + secondary: 'border-white/12 bg-black/20 text-white/80 hover:bg-white/[0.08]', + prose: 'prose-invert prose-a:text-slate-200 prose-strong:text-white', + }, +} + +function cx(...parts) { + return parts.filter(Boolean).join(' ') +} + +function readHiddenAnnouncements() { + if (typeof window === 'undefined') return {} + + try { + const raw = window.localStorage.getItem(HOMEPAGE_ANNOUNCEMENT_STORAGE_KEY) + const parsed = raw ? JSON.parse(raw) : {} + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +function writeHiddenAnnouncements(payload) { + if (typeof window === 'undefined') return + window.localStorage.setItem(HOMEPAGE_ANNOUNCEMENT_STORAGE_KEY, JSON.stringify(payload)) +} + +export default function HomepageAnnouncement({ announcement, mode = 'live' }) { + const [hidden, setHidden] = React.useState(false) + const isLiveMode = mode === 'live' + const isPreviewMode = mode === 'preview' + + React.useEffect(() => { + if (!isLiveMode || !announcement?.id) { + setHidden(false) + return + } + + const hiddenAnnouncements = readHiddenAnnouncements() + setHidden(Number(hiddenAnnouncements[String(announcement.id)] || 0) === Number(announcement.dismiss_version || 1)) + }, [announcement, isLiveMode]) + + if (!announcement) { + return null + } + + const preset = PRESETS[announcement.gradient_preset] || PRESETS.nova_aurora + const overlayOpacity = Math.max(0, Math.min(100, Number(announcement.overlay_opacity ?? 55))) + + const dismiss = () => { + if (!isLiveMode || !announcement?.id) return + + const next = { + ...readHiddenAnnouncements(), + [String(announcement.id)]: Number(announcement.dismiss_version || 1), + } + + writeHiddenAnnouncements(next) + setHidden(true) + } + + const restore = () => { + if (!isLiveMode || !announcement?.id) return + + const next = readHiddenAnnouncements() + delete next[String(announcement.id)] + writeHiddenAnnouncements(next) + setHidden(false) + } + + if (hidden && isLiveMode) { + return ( +
+
+ +
+
+ ) + } + + return ( +
+
+
+ {announcement.background_image_url ? ( +
+ +
+
+ ) : null} + +
+
+ + {announcement.is_dismissible && isLiveMode ? ( + + ) : null} + +
+
+ {announcement.badge_text ? ( +
+ {announcement.badge_text} +
+ ) : null} + +

+ {announcement.title} +

+ + {announcement.subtitle ? ( +

+ {announcement.subtitle} +

+ ) : null} + + {announcement.content_html ? ( +
+ ) : null} +
+ +
+
+ {announcement.primary_link ? ( + + {announcement.primary_link.label} + + ) : null} + {announcement.secondary_link ? ( + + {announcement.secondary_link.label} + + ) : null} +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/resources/js/components/homepage/HomepageAnnouncementEditor.jsx b/resources/js/components/homepage/HomepageAnnouncementEditor.jsx new file mode 100644 index 00000000..ec47d1f4 --- /dev/null +++ b/resources/js/components/homepage/HomepageAnnouncementEditor.jsx @@ -0,0 +1,172 @@ +import React, { useCallback, useEffect } from 'react' +import { EditorContent, useEditor } from '@tiptap/react' +import StarterKit from '@tiptap/starter-kit' +import Link from '@tiptap/extension-link' +import Placeholder from '@tiptap/extension-placeholder' + +function ToolbarButton({ onClick, active, disabled, title, children }) { + return ( + + ) +} + +function Divider() { + return
+} + +function Toolbar({ editor }) { + if (!editor) return null + + const addLink = useCallback(() => { + const previous = editor.getAttributes('link').href + const url = window.prompt('URL', previous ?? 'https://') + if (url === null) return + + if (url === '') { + editor.chain().focus().extendMarkRange('link').unsetLink().run() + return + } + + editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run() + }, [editor]) + + return ( +
+ editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold"> + + + + editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic"> + + + + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2"> + H2 + + + editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3"> + H3 + + + + + editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list"> + + + + editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list"> + 123 + + + + + editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote"> + + + + + + + +
+ editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo"> + + + + editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo"> + + +
+
+ ) +} + +export default function HomepageAnnouncementEditor({ + content = '', + onChange, + placeholder = 'Write the announcement message…', + error, + minHeight = 14, +}) { + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + link: false, + heading: { levels: [2, 3] }, + code: false, + codeBlock: false, + horizontalRule: false, + }), + Link.configure({ + openOnClick: false, + HTMLAttributes: { + class: 'text-sky-300 underline hover:text-sky-200', + rel: 'noopener noreferrer nofollow', + }, + }), + Placeholder.configure({ placeholder }), + ], + immediatelyRender: false, + content, + editorProps: { + attributes: { + class: [ + 'prose prose-invert prose-sm max-w-none', + 'focus:outline-none', + 'px-4 py-3', + 'prose-headings:text-white prose-headings:font-bold', + 'prose-p:text-zinc-200 prose-p:leading-relaxed', + 'prose-a:text-sky-300 prose-a:no-underline hover:prose-a:text-sky-200', + 'prose-blockquote:border-l-sky-500/50 prose-blockquote:text-zinc-400', + 'prose-ul:text-zinc-200 prose-ol:text-zinc-200', + ].join(' '), + style: `min-height: ${minHeight}rem`, + }, + }, + onUpdate: ({ editor: currentEditor }) => { + onChange?.(currentEditor.getHTML()) + }, + }) + + useEffect(() => { + if (editor && content !== editor.getHTML()) { + editor.commands.setContent(content || '', false) + onChange?.(content || '') + } + }, [content, editor, onChange]) + + return ( +
+
+ + +
+ + {error ?

{error}

: null} +
+ ) +} \ No newline at end of file diff --git a/resources/views/web/home/announcement.blade.php b/resources/views/web/home/announcement.blade.php new file mode 100644 index 00000000..a54b90a9 --- /dev/null +++ b/resources/views/web/home/announcement.blade.php @@ -0,0 +1,61 @@ +@php + $homepageAnnouncement = is_array($announcement ?? null) ? $announcement : null; + $overlayOpacity = max(0, min(100, (int) ($homepageAnnouncement['overlay_opacity'] ?? 55))); +@endphp + +@if ($homepageAnnouncement) +
+
+ @if (!empty($homepageAnnouncement['background_image_url'])) +
+ +
+
+ @endif + +
+
+ +
+
+ @if (!empty($homepageAnnouncement['badge_text'])) +
+ {{ $homepageAnnouncement['badge_text'] }} +
+ @endif + +

+ {{ $homepageAnnouncement['title'] ?? '' }} +

+ + @if (!empty($homepageAnnouncement['subtitle'])) +

+ {{ $homepageAnnouncement['subtitle'] }} +

+ @endif + + @if (!empty($homepageAnnouncement['content_html'])) +
+ {!! $homepageAnnouncement['content_html'] !!} +
+ @endif +
+ +
+
+ @if (!empty($homepageAnnouncement['primary_link']['url']) && !empty($homepageAnnouncement['primary_link']['label'])) + + {{ $homepageAnnouncement['primary_link']['label'] }} + + @endif + @if (!empty($homepageAnnouncement['secondary_link']['url']) && !empty($homepageAnnouncement['secondary_link']['label'])) + + {{ $homepageAnnouncement['secondary_link']['label'] }} + + @endif +
+
+
+
+
+@endif \ No newline at end of file diff --git a/tests/Feature/HomepageAnnouncementServiceTest.php b/tests/Feature/HomepageAnnouncementServiceTest.php new file mode 100644 index 00000000..b1620420 --- /dev/null +++ b/tests/Feature/HomepageAnnouncementServiceTest.php @@ -0,0 +1,97 @@ +clearActiveCache(); +}); + +function homepageAnnouncement(array $overrides = []): HomepageAnnouncement +{ + return HomepageAnnouncement::query()->create(array_merge([ + 'title' => 'Skinbase Nova is live.', + 'subtitle' => 'A new chapter for the Skinbase creative community.', + 'badge_text' => 'Launch Day · 1 May 2026', + 'content_html' => '

Today, 1 May 2026, Skinbase begins a new chapter.

', + 'type' => HomepageAnnouncement::TYPE_LAUNCH, + 'status' => HomepageAnnouncement::STATUS_PUBLISHED, + 'is_active' => true, + 'starts_at' => now()->subHour(), + 'ends_at' => null, + 'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED, + 'priority' => 100, + 'is_dismissible' => true, + 'dismiss_version' => 1, + 'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA, + 'theme_preset' => 'launch', + ], $overrides)); +} + +it('returns the visible published active homepage announcement', function (): void { + $expected = homepageAnnouncement(); + + $active = app(HomepageAnnouncementService::class)->getActiveForHomepage(); + + expect($active?->id)->toBe($expected->id); +}); + +it('does not return a future announcement', function (): void { + homepageAnnouncement([ + 'starts_at' => now()->addHour(), + ]); + + expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull(); +}); + +it('does not return an expired announcement', function (): void { + homepageAnnouncement([ + 'starts_at' => now()->subDays(2), + 'ends_at' => now()->subMinute(), + ]); + + expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull(); +}); + +it('does not return a draft announcement', function (): void { + homepageAnnouncement([ + 'status' => HomepageAnnouncement::STATUS_DRAFT, + ]); + + expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull(); +}); + +it('returns the highest priority visible announcement', function (): void { + $lower = homepageAnnouncement([ + 'priority' => 10, + 'title' => 'Lower priority', + ]); + + $higher = homepageAnnouncement([ + 'priority' => 900, + 'title' => 'Higher priority', + ]); + + $active = app(HomepageAnnouncementService::class)->getActiveForHomepage(); + + expect($active?->id) + ->not->toBe($lower->id) + ->toBe($higher->id); +}); + +it('sanitizes announcement html content', function (): void { + $sanitized = app(HomepageAnnouncementSanitizer::class)->sanitizeHtml('

Hello

Click

Title

'); + + expect($sanitized) + ->toContain('

Hello

') + ->toContain('

Title

') + ->not->toContain('not->toContain('javascript:') + ->not->toContain('onclick='); +}); \ No newline at end of file diff --git a/tests/Unit/HomepageAnnouncementModuleTest.php b/tests/Unit/HomepageAnnouncementModuleTest.php new file mode 100644 index 00000000..fb196487 --- /dev/null +++ b/tests/Unit/HomepageAnnouncementModuleTest.php @@ -0,0 +1,286 @@ +id(); + $table->string('name')->nullable(); + $table->string('username')->nullable(); + $table->string('email')->nullable(); + $table->string('password')->nullable(); + $table->string('role')->default('user'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('homepage_announcements', function (Blueprint $table): void { + $table->id(); + $table->string('title', 180); + $table->string('subtitle', 255)->nullable(); + $table->string('badge_text', 100)->nullable(); + $table->longText('content_html')->nullable(); + $table->string('type', 40)->default('announcement'); + $table->string('status', 40)->default('draft'); + $table->boolean('is_active')->default(true); + $table->dateTime('starts_at')->nullable(); + $table->dateTime('ends_at')->nullable(); + $table->string('primary_link_label', 80)->nullable(); + $table->string('primary_link_type', 40)->nullable(); + $table->string('primary_link_url', 2048)->nullable(); + $table->string('primary_link_target_type', 40)->nullable(); + $table->unsignedBigInteger('primary_link_target_id')->nullable(); + $table->string('secondary_link_label', 80)->nullable(); + $table->string('secondary_link_type', 40)->nullable(); + $table->string('secondary_link_url', 2048)->nullable(); + $table->string('secondary_link_target_type', 40)->nullable(); + $table->unsignedBigInteger('secondary_link_target_id')->nullable(); + $table->string('background_type', 40)->nullable(); + $table->string('background_image', 2048)->nullable(); + $table->string('gradient_preset', 80)->nullable(); + $table->string('theme_preset', 80)->nullable(); + $table->string('text_color', 32)->nullable(); + $table->unsignedTinyInteger('overlay_opacity')->nullable(); + $table->string('placement', 80)->default('homepage_after_featured'); + $table->integer('priority')->default(0); + $table->boolean('is_dismissible')->default(true); + $table->unsignedInteger('dismiss_version')->default(1); + $table->unsignedBigInteger('created_by')->nullable(); + $table->unsignedBigInteger('updated_by')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('user_profiles', function (Blueprint $table): void { + $table->id(); + $table->unsignedBigInteger('user_id')->unique(); + $table->string('avatar_hash')->nullable(); + $table->timestamps(); + }); + + app(HomepageAnnouncementService::class)->clearActiveCache(); +}); + +afterEach(function (): void { + Schema::dropIfExists('homepage_announcements'); + Schema::dropIfExists('user_profiles'); + Schema::dropIfExists('users'); + \Mockery::close(); +}); + +function announcementPayload(array $overrides = []): array +{ + return array_merge([ + 'title' => 'Skinbase Nova is live.', + 'subtitle' => 'A new chapter for the Skinbase creative community.', + 'badge_text' => 'Launch Day · 1 May 2026', + 'content_html' => '

Today, 1 May 2026, Skinbase begins a new chapter.

', + 'type' => HomepageAnnouncement::TYPE_LAUNCH, + 'status' => HomepageAnnouncement::STATUS_PUBLISHED, + 'is_active' => true, + 'starts_at' => now()->subHour(), + 'ends_at' => null, + 'primary_link_label' => null, + 'primary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE, + 'primary_link_url' => null, + 'primary_link_target_type' => null, + 'primary_link_target_id' => null, + 'secondary_link_label' => null, + 'secondary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE, + 'secondary_link_url' => null, + 'secondary_link_target_type' => null, + 'secondary_link_target_id' => null, + 'background_type' => null, + 'background_image' => null, + 'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA, + 'theme_preset' => 'launch', + 'text_color' => null, + 'overlay_opacity' => 55, + 'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED, + 'priority' => 100, + 'is_dismissible' => true, + 'dismiss_version' => 1, + ], $overrides); +} + +function createAnnouncement(array $overrides = []): HomepageAnnouncement +{ + return HomepageAnnouncement::query()->create(announcementPayload($overrides)); +} + +function adminUser(): User +{ + return User::query()->create([ + 'name' => 'Admin User', + 'username' => 'adminuser', + 'email' => 'admin@example.com', + 'password' => Hash::make('password'), + 'role' => 'admin', + 'is_active' => true, + ]); +} + +it('returns the visible published active homepage announcement', function (): void { + $expected = createAnnouncement(); + + $active = app(HomepageAnnouncementService::class)->getActiveForHomepage(); + + expect($active?->id)->toBe($expected->id); +}); + +it('does not return a future announcement', function (): void { + createAnnouncement([ + 'starts_at' => now()->addHour(), + ]); + + expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull(); +}); + +it('does not return an expired announcement', function (): void { + createAnnouncement([ + 'starts_at' => now()->subDays(2), + 'ends_at' => now()->subMinute(), + ]); + + expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull(); +}); + +it('does not return a draft announcement', function (): void { + createAnnouncement([ + 'status' => HomepageAnnouncement::STATUS_DRAFT, + ]); + + expect(app(HomepageAnnouncementService::class)->getActiveForHomepage())->toBeNull(); +}); + +it('returns the highest priority visible announcement', function (): void { + createAnnouncement([ + 'priority' => 10, + 'title' => 'Lower priority', + ]); + + $higher = createAnnouncement([ + 'priority' => 900, + 'title' => 'Higher priority', + ]); + + $active = app(HomepageAnnouncementService::class)->getActiveForHomepage(); + + expect($active?->id)->toBe($higher->id); +}); + +it('homepage payload includes the announcement prop', function (): void { + $html = view('web.home', [ + 'seo' => [], + 'useUnifiedSeo' => true, + 'meta' => [], + 'props' => [ + 'hero' => [], + 'announcement' => [ + 'id' => 42, + 'dismiss_version' => 1, + 'title' => 'Skinbase Nova is live.', + 'subtitle' => 'A new chapter.', + 'badge_text' => 'Launch', + 'content_html' => '

Hello

', + 'gradient_preset' => 'nova_aurora', + 'theme_preset' => 'launch', + 'background_image_url' => null, + 'is_dismissible' => true, + 'overlay_opacity' => 55, + 'primary_link' => null, + 'secondary_link' => null, + ], + 'community_favorites' => [], + 'hall_of_fame' => [], + 'rising' => [], + 'trending' => [], + 'fresh' => [], + 'collections_featured' => [], + 'collections_trending' => [], + 'collections_editorial' => [], + 'collections_community' => [], + 'world_spotlight' => null, + 'groups' => [], + 'tags' => [], + 'creators' => [], + 'news' => [], + ], + ])->render(); + + expect($html)->toContain('"announcement":{"id":42'); +}); + +it('preview sanitizes html content', function (): void { + $admin = adminUser(); + + $this->withoutMiddleware(\App\Http\Middleware\HandleInertiaRequests::class); + + $response = $this->actingAs($admin)->post(route('admin.homepage-announcements.preview'), [ + 'title' => 'Preview announcement', + 'subtitle' => 'Unsafe html should be stripped.', + 'badge_text' => 'Preview', + 'content_html' => '

Hello

Visit', + 'type' => HomepageAnnouncement::TYPE_NOTICE, + 'status' => HomepageAnnouncement::STATUS_PUBLISHED, + 'is_active' => true, + 'starts_at' => now()->toIso8601String(), + 'priority' => 10, + 'is_dismissible' => true, + 'dismiss_version' => 1, + 'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA, + 'theme_preset' => 'announcement', + 'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED, + 'overlay_opacity' => 55, + 'primary_link_type' => HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, + 'primary_link_label' => 'Open', + 'primary_link_url' => '/explore', + 'secondary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE, + ]); + + $response + ->assertOk() + ->assertJsonPath('announcement.content_html', '

Hello

Visit'); +}); + +it('preview rejects unsafe custom links', function (): void { + $admin = adminUser(); + + $this->withoutMiddleware(\App\Http\Middleware\HandleInertiaRequests::class); + + $this->actingAs($admin) + ->from(route('admin.homepage-announcements.create')) + ->post(route('admin.homepage-announcements.preview'), [ + 'title' => 'Unsafe CTA', + 'type' => HomepageAnnouncement::TYPE_NOTICE, + 'status' => HomepageAnnouncement::STATUS_PUBLISHED, + 'is_active' => true, + 'priority' => 0, + 'is_dismissible' => true, + 'dismiss_version' => 1, + 'gradient_preset' => HomepageAnnouncement::GRADIENT_NOVA_AURORA, + 'theme_preset' => 'announcement', + 'placement' => HomepageAnnouncement::PLACEMENT_HOMEPAGE_AFTER_FEATURED, + 'overlay_opacity' => 55, + 'primary_link_type' => HomepageAnnouncement::LINK_TYPE_CUSTOM_URL, + 'primary_link_label' => 'Do not allow', + 'primary_link_url' => 'javascript:alert(1)', + 'secondary_link_type' => HomepageAnnouncement::LINK_TYPE_NONE, + ]) + ->assertSessionHasErrors(['primary_link_url']); +}); \ No newline at end of file