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 ? (
+

+ ) : (
+
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}
+
+
+
+
+
+
+
+ )
+}
\ 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">
+
+
+
+
+
+
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
+
+
+
+
+
+
+@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
ClickTitle
');
+
+ expect($sanitized)
+ ->toContain('Hello
')
+ ->toContain('Title
')
+ ->not->toContain('
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