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