import React from 'react' import { Head, Link, useForm, usePage } from '@inertiajs/react' import AdminLayout from '../../../Layouts/AdminLayout' import HomepageAnnouncement from '../../../components/homepage/HomepageAnnouncement' import HomepageAnnouncementEditor from '../../../components/homepage/HomepageAnnouncementEditor' import Checkbox from '../../../components/ui/Checkbox' import DateTimePicker from '../../../components/ui/DateTimePicker' import NovaSelect from '../../../components/ui/NovaSelect' import ShareToast from '../../../components/ui/ShareToast' const BACKGROUND_IMAGE_ACCEPT = 'image/jpeg,image/jpg,image/png,image/webp' const BACKGROUND_IMAGE_MAX_BYTES = 5 * 1024 * 1024 const FORM_TABS = [ { id: 'overview', label: 'Overview', description: 'Identity, status, and schedule.' }, { id: 'content', label: 'Content', description: 'Message body and CTA links.' }, { id: 'design', label: 'Design', description: 'Visual treatment and background media.' }, { id: 'behavior', label: 'Behavior', description: 'Dismiss rules and placement.' }, ] const FIELD_TAB_MAP = { title: 'overview', badge_text: 'overview', subtitle: 'overview', type: 'overview', status: 'overview', priority: 'overview', is_active: 'overview', starts_at: 'overview', ends_at: 'overview', content_html: 'content', primary_link_label: 'content', primary_link_type: 'content', primary_link_url: 'content', primary_link_target_id: 'content', secondary_link_label: 'content', secondary_link_type: 'content', secondary_link_url: 'content', secondary_link_target_id: 'content', gradient_preset: 'design', theme_preset: 'design', overlay_opacity: 'design', background_image: 'design', background_image_file: 'design', remove_background_image: 'design', dismiss_version: 'behavior', placement: 'behavior', is_dismissible: 'behavior', } function isIntegerLike(value) { if (typeof value === 'number') return Number.isInteger(value) if (typeof value !== 'string') return false const normalized = value.trim() return normalized !== '' && /^-?\d+$/.test(normalized) } function isSafeClientUrl(value) { const normalized = String(value || '').trim() if (!normalized) return true return normalized.startsWith('/') || normalized.startsWith('https://') } function firstErrorMessage(errors) { const firstKey = Object.keys(errors || {})[0] if (!firstKey) return null const value = errors[firstKey] return Array.isArray(value) ? value[0] : value } function getCsrfToken() { if (typeof document === 'undefined') return '' return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' } function FieldError({ error }) { if (!error) return null return

{error}

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

{title}

{description ?

{description}

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

{title}

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

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

{activeTab === 'overview' ? ( <>
form.setData('title', event.target.value)} error={form.errors.title} maxLength={180} /> form.setData('badge_text', event.target.value)} error={form.errors.badge_text} maxLength={100} />
form.setData('subtitle', event.target.value)} error={form.errors.subtitle} maxLength={255} />
form.setData('type', nextValue)} options={options.types} error={form.errors.type} /> form.setData('status', nextValue)} options={options.statuses} error={form.errors.status} /> form.setData('priority', event.target.value)} error={form.errors.priority} inputMode="numeric" />
form.setData('is_active', event.target.checked)} help="Inactive announcements never surface even when published." />
form.setData('starts_at', nextValue)} error={form.errors.starts_at} /> form.setData('ends_at', nextValue)} error={form.errors.ends_at} />
) : null} {activeTab === 'content' ? ( <>
Announcement message form.setData('content_html', nextValue)} placeholder="Write the launch message with headings, lists, links, quotes, and highlighted copy." error={form.errors.content_html} minHeight={14} />
Supported formatting matches the homepage sanitizer: paragraphs, bold, italic, links, lists, H2, H3, and blockquotes.
) : null} {activeTab === 'design' ? (
form.setData('gradient_preset', nextValue)} options={options.gradients} error={form.errors.gradient_preset} /> form.setData('theme_preset', nextValue)} options={options.themes} error={form.errors.theme_preset} />
form.setData('overlay_opacity', event.target.value)} error={form.errors.overlay_opacity} inputMode="numeric" />
form.setData('background_image', event.target.value)} error={form.errors.background_image} placeholder="/storage/homepage-announcements/... or https://..." /> { form.setData('remove_background_image', event.target.checked) if (event.target.checked) { setBackgroundImageError('') form.setData('background_image_file', null) if (backgroundPreviewUrl?.startsWith?.('blob:')) { URL.revokeObjectURL(backgroundPreviewUrl) } setBackgroundPreviewUrl('') } }} help="Turn this on to clear the saved background image on the next save." />
) : null} {activeTab === 'behavior' ? (
form.setData('dismiss_version', event.target.value)} error={form.errors.dismiss_version} inputMode="numeric" /> form.setData('placement', nextValue)} options={options.placements} error={form.errors.placement} />
form.setData('is_dismissible', event.target.checked)} help="When disabled, the card remains visible and no restore pill is shown." />
) : null}
) }