Add homepage announcement module
This commit is contained in:
451
app/Http/Controllers/Settings/HomepageAnnouncementController.php
Normal file
451
app/Http/Controllers/Settings/HomepageAnnouncementController.php
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Settings\UpsertHomepageAnnouncementRequest;
|
||||||
|
use App\Models\HomepageAnnouncement;
|
||||||
|
use App\Services\HomepageAnnouncementService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class HomepageAnnouncementController extends Controller
|
||||||
|
{
|
||||||
|
private const BACKGROUND_WEBP_QUALITY = 84;
|
||||||
|
|
||||||
|
public function __construct(private readonly HomepageAnnouncementService $announcements)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$announcements = HomepageAnnouncement::query()
|
||||||
|
->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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $attributes
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, array<int, array<string, string>>>
|
||||||
|
*/
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Settings;
|
||||||
|
|
||||||
|
use App\Models\HomepageAnnouncement;
|
||||||
|
use App\Services\HomepageAnnouncementSanitizer;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpsertHomepageAnnouncementRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
197
app/Models/HomepageAnnouncement.php
Normal file
197
app/Models/HomepageAnnouncement.php
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
class HomepageAnnouncement extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
public const STATUS_DRAFT = 'draft';
|
||||||
|
public const STATUS_PUBLISHED = 'published';
|
||||||
|
public const STATUS_ARCHIVED = 'archived';
|
||||||
|
|
||||||
|
public const TYPE_ANNOUNCEMENT = 'announcement';
|
||||||
|
public const TYPE_LAUNCH = 'launch';
|
||||||
|
public const TYPE_NEWS = 'news';
|
||||||
|
public const TYPE_WORLD = 'world';
|
||||||
|
public const TYPE_EVENT = 'event';
|
||||||
|
public const TYPE_NOTICE = 'notice';
|
||||||
|
public const TYPE_MAINTENANCE = 'maintenance';
|
||||||
|
|
||||||
|
public const PLACEMENT_HOMEPAGE_AFTER_FEATURED = 'homepage_after_featured';
|
||||||
|
|
||||||
|
public const LINK_TYPE_NONE = 'none';
|
||||||
|
public const LINK_TYPE_CUSTOM_URL = 'custom_url';
|
||||||
|
public const LINK_TYPE_NEWS = 'news';
|
||||||
|
public const LINK_TYPE_WORLD = 'world';
|
||||||
|
public const LINK_TYPE_ARTWORK = 'artwork';
|
||||||
|
public const LINK_TYPE_COLLECTION = 'collection';
|
||||||
|
public const LINK_TYPE_GROUP = 'group';
|
||||||
|
public const LINK_TYPE_PROFILE = 'profile';
|
||||||
|
public const LINK_TYPE_EXPLORE = 'explore';
|
||||||
|
public const LINK_TYPE_UPLOAD = 'upload';
|
||||||
|
|
||||||
|
public const GRADIENT_NOVA_AURORA = 'nova_aurora';
|
||||||
|
public const GRADIENT_DEEP_SPACE = 'deep_space';
|
||||||
|
public const GRADIENT_SUNRISE = 'sunrise';
|
||||||
|
public const GRADIENT_OCEAN_GLOW = 'ocean_glow';
|
||||||
|
public const GRADIENT_SPRING_VIBES = 'spring_vibes';
|
||||||
|
public const GRADIENT_FANTASY_REALMS = 'fantasy_realms';
|
||||||
|
public const GRADIENT_MINIMAL_LIGHT = 'minimal_light';
|
||||||
|
public const GRADIENT_DARK_GLASS = 'dark_glass';
|
||||||
|
|
||||||
|
protected $table = 'homepage_announcements';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'title',
|
||||||
|
'subtitle',
|
||||||
|
'badge_text',
|
||||||
|
'content_html',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'is_active',
|
||||||
|
'starts_at',
|
||||||
|
'ends_at',
|
||||||
|
'primary_link_label',
|
||||||
|
'primary_link_type',
|
||||||
|
'primary_link_url',
|
||||||
|
'primary_link_target_type',
|
||||||
|
'primary_link_target_id',
|
||||||
|
'secondary_link_label',
|
||||||
|
'secondary_link_type',
|
||||||
|
'secondary_link_url',
|
||||||
|
'secondary_link_target_type',
|
||||||
|
'secondary_link_target_id',
|
||||||
|
'background_type',
|
||||||
|
'background_image',
|
||||||
|
'gradient_preset',
|
||||||
|
'theme_preset',
|
||||||
|
'text_color',
|
||||||
|
'overlay_opacity',
|
||||||
|
'placement',
|
||||||
|
'priority',
|
||||||
|
'is_dismissible',
|
||||||
|
'dismiss_version',
|
||||||
|
'created_by',
|
||||||
|
'updated_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => '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');
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Observers/HomepageAnnouncementObserver.php
Normal file
31
app/Observers/HomepageAnnouncementObserver.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\HomepageAnnouncement;
|
||||||
|
use App\Services\HomepageAnnouncementService;
|
||||||
|
|
||||||
|
class HomepageAnnouncementObserver
|
||||||
|
{
|
||||||
|
public function saved(HomepageAnnouncement $announcement): void
|
||||||
|
{
|
||||||
|
app(HomepageAnnouncementService::class)->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
175
app/Services/HomepageAnnouncementSanitizer.php
Normal file
175
app/Services/HomepageAnnouncementSanitizer.php
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use DOMDocument;
|
||||||
|
use DOMElement;
|
||||||
|
use DOMNode;
|
||||||
|
|
||||||
|
class HomepageAnnouncementSanitizer
|
||||||
|
{
|
||||||
|
private const ALLOWED_TAGS = [
|
||||||
|
'p', 'br', 'strong', 'b', 'em', 'i', 'a', 'ul', 'ol', 'li', 'h2', 'h3', 'blockquote',
|
||||||
|
];
|
||||||
|
|
||||||
|
private const ALLOWED_ATTRS = [
|
||||||
|
'a' => ['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(
|
||||||
|
'<?xml encoding="UTF-8"><html><body>' . $encodedHtml . '</body></html>',
|
||||||
|
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://');
|
||||||
|
}
|
||||||
|
}
|
||||||
238
app/Services/HomepageAnnouncementService.php
Normal file
238
app/Services/HomepageAnnouncementService.php
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\Collection;
|
||||||
|
use App\Models\Group;
|
||||||
|
use App\Models\HomepageAnnouncement;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\World;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use cPad\Plugins\News\Models\NewsArticle;
|
||||||
|
|
||||||
|
class HomepageAnnouncementService
|
||||||
|
{
|
||||||
|
public const ACTIVE_CACHE_KEY = 'skinbase:homepage:announcement:active';
|
||||||
|
|
||||||
|
public function __construct(private readonly HomepageAnnouncementSanitizer $sanitizer)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveForHomepage(): ?HomepageAnnouncement
|
||||||
|
{
|
||||||
|
return Cache::remember(self::ACTIVE_CACHE_KEY, now()->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<string, mixed> $attributes
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $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)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ namespace App\Services;
|
|||||||
use App\Models\Artwork;
|
use App\Models\Artwork;
|
||||||
use App\Models\Leaderboard;
|
use App\Models\Leaderboard;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
|
use App\Services\HomepageAnnouncementService;
|
||||||
use App\Services\ArtworkSearchService;
|
use App\Services\ArtworkSearchService;
|
||||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||||
use App\Services\EarlyGrowth\GridFiller;
|
use App\Services\EarlyGrowth\GridFiller;
|
||||||
@@ -57,6 +58,7 @@ final class HomepageService
|
|||||||
private readonly GroupDiscoveryService $groupDiscovery,
|
private readonly GroupDiscoveryService $groupDiscovery,
|
||||||
private readonly LeaderboardService $leaderboards,
|
private readonly LeaderboardService $leaderboards,
|
||||||
private readonly WorldService $worlds,
|
private readonly WorldService $worlds,
|
||||||
|
private readonly HomepageAnnouncementService $homepageAnnouncements,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
@@ -68,9 +70,17 @@ final class HomepageService
|
|||||||
*/
|
*/
|
||||||
public function all(): array
|
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->guestPayloadCacheKey(),
|
||||||
$this->guestPayloadCacheTtl(),
|
[$freshSeconds, $ttl],
|
||||||
fn (): array => $this->buildGuestPayload(),
|
fn (): array => $this->buildGuestPayload(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,6 +129,7 @@ final class HomepageService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'hero' => $this->getHeroArtwork(),
|
'hero' => $this->getHeroArtwork(),
|
||||||
|
'announcement' => $this->homepageAnnouncements->toHomepagePayload($this->homepageAnnouncements->getActiveForHomepage()),
|
||||||
'community_favorites' => $this->getCommunityFavorites(),
|
'community_favorites' => $this->getCommunityFavorites(),
|
||||||
'hall_of_fame' => $this->getHallOfFame(),
|
'hall_of_fame' => $this->getHallOfFame(),
|
||||||
'rising' => $this->getRising(),
|
'rising' => $this->getRising(),
|
||||||
@@ -171,6 +182,7 @@ final class HomepageService
|
|||||||
'is_logged_in' => true,
|
'is_logged_in' => true,
|
||||||
'user_data' => $this->getUserData($user),
|
'user_data' => $this->getUserData($user),
|
||||||
'hero' => $this->getHeroArtwork(),
|
'hero' => $this->getHeroArtwork(),
|
||||||
|
'announcement' => $this->homepageAnnouncements->toHomepagePayload($this->homepageAnnouncements->getActiveForHomepage()),
|
||||||
'community_favorites' => $this->getCommunityFavorites(),
|
'community_favorites' => $this->getCommunityFavorites(),
|
||||||
'hall_of_fame' => $this->getHallOfFame(),
|
'hall_of_fame' => $this->getHallOfFame(),
|
||||||
'for_you' => $this->getForYouPreview($user),
|
'for_you' => $this->getForYouPreview($user),
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
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->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
59
database/seeders/HomepageAnnouncementLaunchSeeder.php
Normal file
59
database/seeders/HomepageAnnouncementLaunchSeeder.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\HomepageAnnouncement;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use cPad\Plugins\News\Models\NewsArticle;
|
||||||
|
|
||||||
|
final class HomepageAnnouncementLaunchSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$launchStory = NewsArticle::query()->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", [
|
||||||
|
'<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>',
|
||||||
|
'<p>Skinbase Nova is a modern reboot of our creative community for digital art, wallpapers, skins, photography, customization, and discovery.</p>',
|
||||||
|
'<p>We are bringing the spirit of classic Skinbase into a faster, cleaner, and more modern experience — built for creators, fans, and the future.</p>',
|
||||||
|
'<p>Welcome to <strong>Skinbase Nova</strong>.</p>',
|
||||||
|
]),
|
||||||
|
'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,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
667
resources/js/Pages/Admin/HomepageAnnouncements/Form.jsx
Normal file
667
resources/js/Pages/Admin/HomepageAnnouncements/Form.jsx
Normal file
@@ -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 <p className="text-xs text-rose-300">{error}</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTabFromErrors(errors) {
|
||||||
|
const firstKey = Object.keys(errors || {})[0]
|
||||||
|
return FIELD_TAB_MAP[firstKey] || 'overview'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, description, children }) {
|
||||||
|
return (
|
||||||
|
<section className="rounded-[30px] border border-white/10 bg-white/[0.03] p-6">
|
||||||
|
<div className="mb-5">
|
||||||
|
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||||
|
{description ? <p className="mt-1 text-sm text-slate-400">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-5">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextField({ label, value, onChange, error, ...rest }) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-2 text-sm text-slate-200">
|
||||||
|
<span>{label}</span>
|
||||||
|
<input value={value} onChange={onChange} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" {...rest} />
|
||||||
|
<FieldError error={error} />
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToggleField({ label, checked, onChange, help }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-200">
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold text-white">{label}</div>
|
||||||
|
{help ? <div className="mt-1 text-xs leading-6 text-slate-400">{help}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Checkbox checked={checked} onChange={onChange} aria-label={label} variant="sky" size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectField({ label, value, onChange, options, error }) {
|
||||||
|
return (
|
||||||
|
<NovaSelect
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
options={options || []}
|
||||||
|
error={error}
|
||||||
|
searchable={false}
|
||||||
|
className="rounded-2xl bg-black/20"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateTimeField({ label, value, onChange, error }) {
|
||||||
|
return (
|
||||||
|
<DateTimePicker
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
error={error}
|
||||||
|
clearable
|
||||||
|
className="rounded-2xl bg-black/20"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="grid gap-3 text-sm text-slate-200">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span>Upload background image</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.08]"
|
||||||
|
>
|
||||||
|
Browse
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => 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(' ')}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl border border-sky-300/20 bg-sky-400/10 text-sky-100">
|
||||||
|
<i className="fa-solid fa-cloud-arrow-up" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-white">Drop image here or browse</div>
|
||||||
|
<div className="mt-1 text-xs leading-5 text-slate-400">JPG, PNG, or WEBP. Maximum 5 MB. The selected image is previewed here and on the card preview.</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-slate-400">
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">JPG</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">PNG</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">WEBP</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1">Max 5 MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-28 w-full overflow-hidden rounded-[20px] border border-white/10 bg-slate-950 lg:w-44">
|
||||||
|
{previewUrl ? (
|
||||||
|
<img src={previewUrl} alt="Background preview" className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center px-4 text-center text-sm text-slate-500">No background image selected</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedFileName ? <div className="mt-4 truncate rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-slate-300">Selected file: <span className="text-white">{selectedFileName}</span></div> : null}
|
||||||
|
{!selectedFileName && storedValue ? <div className="mt-4 truncate rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-xs text-slate-400">Stored path: <span className="text-slate-200">{storedValue}</span></div> : null}
|
||||||
|
{error ? <div className="mt-3 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={BACKGROUND_IMAGE_ACCEPT}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
handleFile(event.target.files?.[0] || null)
|
||||||
|
event.target.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkFields({ title, prefix, form, options }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 rounded-[28px] border border-white/10 bg-black/20 p-4">
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-[0.18em] text-white/75">{title}</h3>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextField label="Label" value={form.data[`${prefix}_link_label`]} onChange={(event) => form.setData(`${prefix}_link_label`, event.target.value)} error={form.errors[`${prefix}_link_label`]} maxLength={80} />
|
||||||
|
<SelectField label="Link type" value={form.data[`${prefix}_link_type`]} onChange={(nextValue) => form.setData(`${prefix}_link_type`, nextValue)} options={options.linkTypes} error={form.errors[`${prefix}_link_type`]} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextField label="Fallback URL" value={form.data[`${prefix}_link_url`]} onChange={(event) => form.setData(`${prefix}_link_url`, event.target.value)} error={form.errors[`${prefix}_link_url`]} placeholder="/explore or https://example.com" maxLength={2048} />
|
||||||
|
<TextField label="Target ID" value={form.data[`${prefix}_link_target_id`]} onChange={(event) => form.setData(`${prefix}_link_target_id`, event.target.value)} error={form.errors[`${prefix}_link_target_id`]} placeholder="Optional entity id" inputMode="numeric" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<AdminLayout title={isEditing ? 'Edit Homepage Announcement' : 'Create Homepage Announcement'} subtitle="Compose, schedule, preview, and publish a premium homepage announcement card.">
|
||||||
|
<Head title={isEditing ? 'Admin · Edit Homepage Announcement' : 'Admin · Create Homepage Announcement'} />
|
||||||
|
<ShareToast
|
||||||
|
key={toast.id}
|
||||||
|
message={toast.message}
|
||||||
|
visible={toast.visible}
|
||||||
|
variant={toast.variant}
|
||||||
|
duration={toast.variant === 'error' ? 3200 : 2200}
|
||||||
|
onHide={() => setToast((current) => ({ ...current, visible: false }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||||
|
{flash.error ? <div className="mb-6 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||||
|
|
||||||
|
<div className="sticky top-4 z-30 mb-6 rounded-[28px] border border-white/10 bg-slate-950/85 px-4 py-4 shadow-[0_20px_60px_rgba(0,0,0,0.35)] backdrop-blur-xl">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<Link href={indexUrl} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M10.5 3.5 6 8l4.5 4.5" />
|
||||||
|
</svg>
|
||||||
|
Back to announcements
|
||||||
|
</Link>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button type="button" onClick={() => submit('draft')} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white">Save draft</button>
|
||||||
|
<button type="button" onClick={() => submit('published')} className="rounded-full border border-emerald-300/20 bg-emerald-300/10 px-4 py-2 text-sm font-semibold text-emerald-100">Publish</button>
|
||||||
|
<button type="button" onClick={() => submit('archived')} className="rounded-full border border-amber-300/20 bg-amber-300/10 px-4 py-2 text-sm font-semibold text-amber-100">Archive</button>
|
||||||
|
{destroyUrl ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm('Delete this homepage announcement?')) return
|
||||||
|
form.delete(destroyUrl, { preserveScroll: true })
|
||||||
|
}}
|
||||||
|
className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid items-start gap-6 xl:grid-cols-[minmax(0,1fr)_26rem] 2xl:grid-cols-[minmax(0,1fr)_28rem]">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="sticky top-[6.75rem] z-20 rounded-[28px] border border-white/10 bg-slate-950/80 p-2 backdrop-blur-xl">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{FORM_TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={[
|
||||||
|
'rounded-2xl px-4 py-3 text-sm font-semibold transition',
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-sky-300/14 text-sky-100 ring-1 ring-sky-300/25'
|
||||||
|
: 'text-slate-300 hover:bg-white/[0.04] hover:text-white',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="px-2 pt-3 text-sm text-slate-400">{FORM_TABS.find((tab) => tab.id === activeTab)?.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'overview' ? (
|
||||||
|
<>
|
||||||
|
<Section title="Basic" description="Core identity, status, and visibility controls.">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextField label="Title" value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} error={form.errors.title} maxLength={180} />
|
||||||
|
<TextField label="Badge text" value={form.data.badge_text} onChange={(event) => form.setData('badge_text', event.target.value)} error={form.errors.badge_text} maxLength={100} />
|
||||||
|
</div>
|
||||||
|
<TextField label="Subtitle" value={form.data.subtitle} onChange={(event) => form.setData('subtitle', event.target.value)} error={form.errors.subtitle} maxLength={255} />
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<SelectField label="Type" value={form.data.type} onChange={(nextValue) => form.setData('type', nextValue)} options={options.types} error={form.errors.type} />
|
||||||
|
<SelectField label="Status" value={form.data.status} onChange={(nextValue) => form.setData('status', nextValue)} options={options.statuses} error={form.errors.status} />
|
||||||
|
<TextField label="Priority" value={form.data.priority} onChange={(event) => form.setData('priority', event.target.value)} error={form.errors.priority} inputMode="numeric" />
|
||||||
|
</div>
|
||||||
|
<ToggleField label="Announcement is active" checked={Boolean(form.data.is_active)} onChange={(event) => form.setData('is_active', event.target.checked)} help="Inactive announcements never surface even when published." />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Schedule" description="Keep launch cards time-bound and predictable.">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<DateTimeField label="Starts at" value={form.data.starts_at} onChange={(nextValue) => form.setData('starts_at', nextValue)} error={form.errors.starts_at} />
|
||||||
|
<DateTimeField label="Ends at" value={form.data.ends_at} onChange={(nextValue) => form.setData('ends_at', nextValue)} error={form.errors.ends_at} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === 'content' ? (
|
||||||
|
<>
|
||||||
|
<Section title="Content" description="Only sanitized HTML is stored and rendered on the homepage.">
|
||||||
|
<div className="grid gap-3 text-sm text-slate-200">
|
||||||
|
<span>Announcement message</span>
|
||||||
|
<HomepageAnnouncementEditor
|
||||||
|
content={form.data.content_html || ''}
|
||||||
|
onChange={(nextValue) => 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}
|
||||||
|
/>
|
||||||
|
<div className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-xs leading-6 text-slate-400">
|
||||||
|
Supported formatting matches the homepage sanitizer: paragraphs, bold, italic, links, lists, H2, H3, and blockquotes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Links" description="Use entity targets when you know the id, or provide a fallback URL for a stable manual route.">
|
||||||
|
<LinkFields title="Primary CTA" prefix="primary" form={form} options={options} />
|
||||||
|
<LinkFields title="Secondary CTA" prefix="secondary" form={form} options={options} />
|
||||||
|
</Section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === 'design' ? (
|
||||||
|
<Section title="Design" description="Choose a launch-ready gradient, optional background image, and glass intensity.">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<SelectField label="Gradient preset" value={form.data.gradient_preset} onChange={(nextValue) => form.setData('gradient_preset', nextValue)} options={options.gradients} error={form.errors.gradient_preset} />
|
||||||
|
<SelectField label="Theme preset" value={form.data.theme_preset} onChange={(nextValue) => form.setData('theme_preset', nextValue)} options={options.themes} error={form.errors.theme_preset} />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextField label="Overlay opacity" value={form.data.overlay_opacity} onChange={(event) => form.setData('overlay_opacity', event.target.value)} error={form.errors.overlay_opacity} inputMode="numeric" />
|
||||||
|
<div className="hidden md:block" />
|
||||||
|
</div>
|
||||||
|
<TextField label="Background image path or URL" value={form.data.background_image} onChange={(event) => form.setData('background_image', event.target.value)} error={form.errors.background_image} placeholder="/storage/homepage-announcements/... or https://..." />
|
||||||
|
<BackgroundImageDropzone
|
||||||
|
previewUrl={backgroundPreviewUrl}
|
||||||
|
storedValue={form.data.background_image}
|
||||||
|
selectedFileName={form.data.background_image_file?.name || ''}
|
||||||
|
error={backgroundImageError || form.errors.background_image_file}
|
||||||
|
onSelect={applyBackgroundFile}
|
||||||
|
/>
|
||||||
|
<ToggleField label="Remove stored background image" checked={Boolean(form.data.remove_background_image)} onChange={(event) => {
|
||||||
|
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." />
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === 'behavior' ? (
|
||||||
|
<Section title="Behavior" description="Dismiss controls let you force a fresh surface when the message materially changes.">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<TextField label="Dismiss version" value={form.data.dismiss_version} onChange={(event) => form.setData('dismiss_version', event.target.value)} error={form.errors.dismiss_version} inputMode="numeric" />
|
||||||
|
<SelectField label="Placement" value={form.data.placement} onChange={(nextValue) => form.setData('placement', nextValue)} options={options.placements} error={form.errors.placement} />
|
||||||
|
</div>
|
||||||
|
<ToggleField label="Users can dismiss this card" checked={Boolean(form.data.is_dismissible)} onChange={(event) => form.setData('is_dismissible', event.target.checked)} help="When disabled, the card remains visible and no restore pill is shown." />
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="space-y-6 xl:sticky xl:top-[7.5rem] xl:self-start">
|
||||||
|
<div>
|
||||||
|
<Section title="Preview" description="Refresh the preview to render the sanitized content and resolved CTA payload exactly as the homepage card sees it.">
|
||||||
|
<div className="-mx-6 -mt-6 mb-5 border-b border-white/10 bg-slate-950/92 px-6 py-4 backdrop-blur-xl">
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<button type="button" onClick={runPreview} disabled={previewBusy} className="rounded-full border border-sky-300/20 bg-sky-300/12 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18 disabled:opacity-60">{previewBusy ? 'Refreshing preview…' : 'Refresh preview'}</button>
|
||||||
|
<button type="button" onClick={() => submit()} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Save changes</button>
|
||||||
|
</div>
|
||||||
|
{previewError ? <p className="mt-3 text-sm text-rose-300">{previewError}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-[30px] border border-white/10 bg-black/20 py-2">
|
||||||
|
<HomepageAnnouncement announcement={previewWithLocalImage} mode="preview" />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
resources/js/Pages/Admin/HomepageAnnouncements/Index.jsx
Normal file
110
resources/js/Pages/Admin/HomepageAnnouncements/Index.jsx
Normal file
@@ -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 (
|
||||||
|
<span className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${tone}`}>
|
||||||
|
<span className={`h-2 w-2 rounded-full ${active ? 'bg-emerald-300' : 'bg-slate-500'}`} />
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomepageAnnouncementsIndex({ announcements, createUrl }) {
|
||||||
|
const { props } = usePage()
|
||||||
|
const flash = props.flash ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="Homepage Announcements" subtitle="Schedule launch cards, homepage notices, and editorial announcements below the featured artwork hero.">
|
||||||
|
<Head title="Admin · Homepage Announcements" />
|
||||||
|
|
||||||
|
{flash.success ? <div className="mb-6 rounded-2xl border border-emerald-300/20 bg-emerald-300/10 px-4 py-3 text-sm text-emerald-100">{flash.success}</div> : null}
|
||||||
|
{flash.error ? <div className="mb-6 rounded-2xl border border-rose-300/20 bg-rose-300/10 px-4 py-3 text-sm text-rose-100">{flash.error}</div> : null}
|
||||||
|
|
||||||
|
<div className="mb-6 flex items-center justify-between gap-4">
|
||||||
|
<div className="max-w-2xl text-sm leading-6 text-slate-400">
|
||||||
|
Only the highest-priority published announcement that is active and inside its visibility window appears on the homepage.
|
||||||
|
</div>
|
||||||
|
<Link href={createUrl} className="rounded-full border border-sky-300/20 bg-sky-300/12 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-300/18">
|
||||||
|
Create announcement
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{(announcements?.data || []).length === 0 ? (
|
||||||
|
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] px-6 py-10 text-center text-slate-400">
|
||||||
|
No homepage announcements exist yet.
|
||||||
|
</div>
|
||||||
|
) : (announcements.data.map((announcement) => (
|
||||||
|
<article key={announcement.id} className="overflow-hidden rounded-[30px] border border-white/10 bg-white/[0.03]">
|
||||||
|
<div className="grid gap-6 border-b border-white/8 px-6 py-6 lg:grid-cols-[minmax(0,1.2fr)_auto] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<StatusBadge status={announcement.status} active={announcement.is_active} />
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">{announcement.type}</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">Priority {announcement.priority}</span>
|
||||||
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-white/70">Dismiss v{announcement.dismiss_version}</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-2xl font-semibold tracking-[-0.04em] text-white">{announcement.title}</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">{formatDateRange(announcement.starts_at, announcement.ends_at)}</p>
|
||||||
|
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-500">{announcement.placement.replaceAll('_', ' ')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3 lg:justify-end">
|
||||||
|
<Link href={announcement.edit_url} className="rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/[0.08]">Edit</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!window.confirm('Delete this homepage announcement?')) return
|
||||||
|
router.delete(announcement.destroy_url, { preserveScroll: true })
|
||||||
|
}}
|
||||||
|
className="rounded-full border border-rose-300/20 bg-rose-300/10 px-4 py-2 text-sm font-semibold text-rose-100 transition hover:bg-rose-300/16"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-black/10 py-2">
|
||||||
|
<HomepageAnnouncement announcement={announcement.preview} mode="preview" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))) }
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{announcements?.last_page > 1 ? (
|
||||||
|
<div className="mt-8 flex items-center justify-between gap-4">
|
||||||
|
<p className="text-xs text-slate-500">Showing {announcements.from}–{announcements.to} of {announcements.total} announcements</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{announcements.links.map((link, index) => (
|
||||||
|
link.url ? (
|
||||||
|
<button
|
||||||
|
key={`${link.label}-${index}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.get(link.url, {}, { preserveScroll: true })}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs transition ${link.active ? 'bg-sky-300/15 text-sky-100' : 'bg-white/[0.04] text-slate-400 hover:bg-white/[0.08] hover:text-white'}`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: link.label }}
|
||||||
|
/>
|
||||||
|
) : <span key={`${link.label}-${index}`} className="rounded-lg px-3 py-1.5 text-xs text-slate-600" dangerouslySetInnerHTML={{ __html: link.label }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</AdminLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
241
resources/js/components/homepage/HomepageAnnouncement.jsx
Normal file
241
resources/js/components/homepage/HomepageAnnouncement.jsx
Normal file
@@ -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 (
|
||||||
|
<section className="px-4 pt-8 sm:px-6 lg:px-8">
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={restore}
|
||||||
|
className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-4 py-2 text-sm font-semibold text-white/90 transition hover:border-white/20 hover:bg-white/[0.1]"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">✨</span>
|
||||||
|
<span>Show Skinbase Nova announcement</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="px-4 pt-8 sm:px-6 lg:px-8">
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<div className={cx('relative overflow-hidden rounded-[2rem] border shadow-[0_28px_90px_rgba(0,0,0,0.35)]', preset.shell)}>
|
||||||
|
{announcement.background_image_url ? (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<img src={announcement.background_image_url} alt="" className="h-full w-full object-cover" />
|
||||||
|
<div className="absolute inset-0 bg-slate-950" style={{ opacity: overlayOpacity / 100 }} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={cx('pointer-events-none absolute inset-0 bg-gradient-to-br', preset.glow)} />
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),transparent_22%,rgba(2,6,23,0.15)_100%)]" />
|
||||||
|
|
||||||
|
{announcement.is_dismissible && isLiveMode ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={dismiss}
|
||||||
|
className="absolute right-5 top-5 z-10 inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-2 text-xs font-semibold uppercase tracking-[0.18em] text-white/70 transition hover:border-white/20 hover:bg-black/45 hover:text-white sm:right-6 sm:top-6 lg:right-8 lg:top-8"
|
||||||
|
aria-label="Dismiss homepage announcement"
|
||||||
|
>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 16 16" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d="M3.5 3.5 12.5 12.5" />
|
||||||
|
<path d="M12.5 3.5 3.5 12.5" />
|
||||||
|
</svg>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={cx(
|
||||||
|
'relative px-6 py-7 sm:px-8 lg:px-10 lg:py-10',
|
||||||
|
isPreviewMode
|
||||||
|
? 'flex min-h-[42rem] flex-col gap-8'
|
||||||
|
: 'grid gap-8 lg:grid-cols-[minmax(0,1.25fr)_auto] lg:items-end'
|
||||||
|
)}>
|
||||||
|
<div className={cx(isPreviewMode ? 'w-full flex-1' : 'max-w-3xl')}>
|
||||||
|
{announcement.badge_text ? (
|
||||||
|
<div className={cx('inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em]', preset.badge)}>
|
||||||
|
{announcement.badge_text}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<h2 className="mt-4 text-3xl font-semibold tracking-[-0.05em] sm:text-4xl lg:text-[3.15rem]">
|
||||||
|
{announcement.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{announcement.subtitle ? (
|
||||||
|
<p className={cx('mt-3 text-base leading-7 text-current/80 sm:text-lg', isPreviewMode ? 'w-full max-w-none' : 'max-w-2xl')}>
|
||||||
|
{announcement.subtitle}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{announcement.content_html ? (
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'mt-5 text-sm leading-7 sm:text-base',
|
||||||
|
'[&_p]:my-0 [&_p+p]:mt-6 [&_ul]:my-5 [&_ol]:my-5 [&_li+li]:mt-1.5 [&_blockquote]:my-5 [&_h2]:mb-3 [&_h2]:mt-7 [&_h3]:mb-2 [&_h3]:mt-6',
|
||||||
|
preset.prose,
|
||||||
|
isPreviewMode ? 'w-full max-w-none' : 'max-w-2xl'
|
||||||
|
)}
|
||||||
|
dangerouslySetInnerHTML={{ __html: announcement.content_html }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cx(
|
||||||
|
isPreviewMode
|
||||||
|
? 'mt-auto flex w-full flex-col gap-4 border-t border-white/10 pt-5'
|
||||||
|
: 'flex flex-col items-start gap-3 lg:items-end'
|
||||||
|
)}>
|
||||||
|
<div className={cx('flex flex-wrap gap-3', isPreviewMode ? 'w-full' : 'lg:justify-end')}>
|
||||||
|
{announcement.primary_link ? (
|
||||||
|
<a href={announcement.primary_link.url} className={cx('inline-flex items-center justify-center rounded-full border px-5 py-3 text-sm font-semibold transition', isPreviewMode ? 'min-w-[11rem]' : '', preset.primary)}>
|
||||||
|
{announcement.primary_link.label}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{announcement.secondary_link ? (
|
||||||
|
<a href={announcement.secondary_link.url} className={cx('inline-flex items-center justify-center rounded-full border px-5 py-3 text-sm font-semibold transition', isPreviewMode ? 'min-w-[11rem]' : '', preset.secondary)}>
|
||||||
|
{announcement.secondary_link.label}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
172
resources/js/components/homepage/HomepageAnnouncementEditor.jsx
Normal file
172
resources/js/components/homepage/HomepageAnnouncementEditor.jsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
|
className={[
|
||||||
|
'inline-flex h-8 w-8 items-center justify-center rounded-lg text-sm transition-colors',
|
||||||
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-400',
|
||||||
|
active
|
||||||
|
? 'bg-sky-600/25 text-sky-300'
|
||||||
|
: 'text-zinc-400 hover:bg-white/[0.06] hover:text-zinc-200',
|
||||||
|
disabled ? 'pointer-events-none opacity-30' : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider() {
|
||||||
|
return <div className="mx-1 h-5 w-px bg-white/10" />
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5 border-b border-white/[0.06] px-2.5 py-2">
|
||||||
|
<ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Bold">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6zm0 8h9a4 4 0 014 4 4 4 0 01-4 4H6z" /></svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Italic">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="19" y1="4" x2="10" y2="4" /><line x1="14" y1="20" x2="5" y2="20" /><line x1="15" y1="4" x2="9" y2="20" /></svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Heading 2">
|
||||||
|
<span className="text-xs font-bold">H2</span>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Heading 3">
|
||||||
|
<span className="text-xs font-bold">H3</span>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolbarButton onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Bullet list">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="9" y1="6" x2="20" y2="6" /><line x1="9" y1="12" x2="20" y2="12" /><line x1="9" y1="18" x2="20" y2="18" /><circle cx="4.5" cy="6" r="1" fill="currentColor" /><circle cx="4.5" cy="12" r="1" fill="currentColor" /><circle cx="4.5" cy="18" r="1" fill="currentColor" /></svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Numbered list">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="10" y1="6" x2="21" y2="6" /><line x1="10" y1="12" x2="21" y2="12" /><line x1="10" y1="18" x2="21" y2="18" /><text x="3" y="8" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">1</text><text x="3" y="14" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">2</text><text x="3" y="20" fontSize="7" fill="currentColor" stroke="none" fontFamily="sans-serif">3</text></svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Quote">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"><path d="M4.583 17.321C3.553 16.227 3 15 3 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311C9.591 11.68 11 13.24 11 15.14c0 .94-.36 1.84-1.001 2.503A3.34 3.34 0 017.559 18.6a3.77 3.77 0 01-2.976-.879zm10.4 0C13.953 16.227 13.4 15 13.4 13.011c0-3.5 2.457-6.637 6.03-8.188l.893 1.378c-3.335 1.804-3.987 4.145-4.247 5.621.537-.278 1.24-.375 1.929-.311 1.986.169 3.395 1.729 3.395 3.629 0 .94-.36 1.84-1.001 2.503a3.34 3.34 0 01-2.44.957 3.77 3.77 0 01-2.976-.879z" /></svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarButton onClick={addLink} active={editor.isActive('link')} title="Link">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71" /></svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-0.5">
|
||||||
|
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Undo">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="1 4 1 10 7 10" /><path d="M3.51 15a9 9 0 102.13-9.36L1 10" /></svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
|
||||||
|
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Redo">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 11-2.13-9.36L23 10" /></svg>
|
||||||
|
</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'overflow-hidden rounded-[28px] border bg-black/20 transition-colors',
|
||||||
|
error
|
||||||
|
? 'border-red-500/60 focus-within:border-red-500/70 focus-within:ring-2 focus-within:ring-red-500/30'
|
||||||
|
: 'border-white/12 hover:border-white/20 focus-within:border-sky-500/50 focus-within:ring-2 focus-within:ring-sky-500/20',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<Toolbar editor={editor} />
|
||||||
|
<EditorContent editor={editor} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p role="alert" className="text-xs text-red-400">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
61
resources/views/web/home/announcement.blade.php
Normal file
61
resources/views/web/home/announcement.blade.php
Normal file
@@ -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)
|
||||||
|
<section class="px-4 pt-8 sm:px-6 lg:px-8">
|
||||||
|
<div class="relative mx-auto max-w-7xl overflow-hidden rounded-[2rem] border 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 shadow-[0_28px_90px_rgba(0,0,0,0.35)]">
|
||||||
|
@if (!empty($homepageAnnouncement['background_image_url']))
|
||||||
|
<div class="absolute inset-0">
|
||||||
|
<img src="{{ $homepageAnnouncement['background_image_url'] }}" alt="" class="h-full w-full object-cover">
|
||||||
|
<div class="absolute inset-0 bg-slate-950" style="opacity: {{ $overlayOpacity / 100 }}"></div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-indigo-400/20 via-sky-400/12 to-transparent"></div>
|
||||||
|
<div class="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),transparent_22%,rgba(2,6,23,0.15)_100%)]"></div>
|
||||||
|
|
||||||
|
<div class="relative grid gap-8 px-6 py-7 sm:px-8 lg:grid-cols-[minmax(0,1.25fr)_auto] lg:items-end lg:px-10 lg:py-10">
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
@if (!empty($homepageAnnouncement['badge_text']))
|
||||||
|
<div class="inline-flex rounded-full border border-cyan-200/20 bg-cyan-300/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-cyan-100">
|
||||||
|
{{ $homepageAnnouncement['badge_text'] }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<h2 class="mt-4 text-3xl font-semibold tracking-[-0.05em] sm:text-4xl lg:text-[3.15rem]">
|
||||||
|
{{ $homepageAnnouncement['title'] ?? '' }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
@if (!empty($homepageAnnouncement['subtitle']))
|
||||||
|
<p class="mt-3 max-w-2xl text-base leading-7 text-white/80 sm:text-lg">
|
||||||
|
{{ $homepageAnnouncement['subtitle'] }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if (!empty($homepageAnnouncement['content_html']))
|
||||||
|
<div class="prose prose-invert mt-5 max-w-2xl text-sm leading-7 prose-a:text-cyan-200 prose-strong:text-white [&_blockquote]:my-5 [&_h2]:mb-3 [&_h2]:mt-7 [&_h3]:mb-2 [&_h3]:mt-6 [&_li+li]:mt-1.5 [&_ol]:my-5 [&_p]:my-0 [&_p+p]:mt-6 [&_ul]:my-5 sm:text-base">
|
||||||
|
{!! $homepageAnnouncement['content_html'] !!}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start gap-3 lg:items-end">
|
||||||
|
<div class="flex flex-wrap gap-3 lg:justify-end">
|
||||||
|
@if (!empty($homepageAnnouncement['primary_link']['url']) && !empty($homepageAnnouncement['primary_link']['label']))
|
||||||
|
<a href="{{ $homepageAnnouncement['primary_link']['url'] }}" class="inline-flex items-center justify-center rounded-full border border-cyan-300/30 bg-cyan-300/15 px-5 py-3 text-sm font-semibold text-cyan-50 transition hover:bg-cyan-300/22">
|
||||||
|
{{ $homepageAnnouncement['primary_link']['label'] }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
@if (!empty($homepageAnnouncement['secondary_link']['url']) && !empty($homepageAnnouncement['secondary_link']['label']))
|
||||||
|
<a href="{{ $homepageAnnouncement['secondary_link']['url'] }}" class="inline-flex items-center justify-center rounded-full border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white/85 transition hover:bg-white/[0.1]">
|
||||||
|
{{ $homepageAnnouncement['secondary_link']['label'] }}
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
@endif
|
||||||
97
tests/Feature/HomepageAnnouncementServiceTest.php
Normal file
97
tests/Feature/HomepageAnnouncementServiceTest.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\HomepageAnnouncement;
|
||||||
|
use App\Services\HomepageAnnouncementSanitizer;
|
||||||
|
use App\Services\HomepageAnnouncementService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
app(HomepageAnnouncementService::class)->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' => '<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>',
|
||||||
|
'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('<p>Hello<script>alert(1)</script></p><a href="javascript:alert(1)" onclick="boom()">Click</a><h2 style="color:red">Title</h2>');
|
||||||
|
|
||||||
|
expect($sanitized)
|
||||||
|
->toContain('<p>Hello</p>')
|
||||||
|
->toContain('<h2>Title</h2>')
|
||||||
|
->not->toContain('<script')
|
||||||
|
->not->toContain('javascript:')
|
||||||
|
->not->toContain('onclick=');
|
||||||
|
});
|
||||||
286
tests/Unit/HomepageAnnouncementModuleTest.php
Normal file
286
tests/Unit/HomepageAnnouncementModuleTest.php
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\HomepageAnnouncement;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\HomepageAnnouncementService;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Schema::dropIfExists('homepage_announcements');
|
||||||
|
Schema::dropIfExists('user_profiles');
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
|
||||||
|
Schema::create('users', function (Blueprint $table): void {
|
||||||
|
$table->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' => '<p><strong>Today, 1 May 2026, Skinbase begins a new chapter.</strong></p>',
|
||||||
|
'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' => '<p>Hello</p>',
|
||||||
|
'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' => '<p>Hello<script>alert(1)</script></p><a href="https://skinbase.top" onclick="evil()">Visit</a>',
|
||||||
|
'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', '<p>Hello</p><a href="https://skinbase.top" rel="noopener noreferrer" target="_blank">Visit</a>');
|
||||||
|
});
|
||||||
|
|
||||||
|
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']);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user