645 lines
45 KiB
JavaScript
645 lines
45 KiB
JavaScript
import React from 'react'
|
|
import { Head, usePage } from '@inertiajs/react'
|
|
import CollectionCard from '../../components/profile/collections/CollectionCard'
|
|
|
|
function getCsrfToken() {
|
|
if (typeof document === 'undefined') return ''
|
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
|
}
|
|
|
|
async function requestJson(url, { method = 'POST', body } = {}) {
|
|
const response = await fetch(url, {
|
|
method,
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': getCsrfToken(),
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
})
|
|
|
|
const payload = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
throw new Error(payload?.message || 'Request failed.')
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
function isoToLocalInput(value) {
|
|
if (!value) return ''
|
|
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) return ''
|
|
|
|
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
|
|
return local.toISOString().slice(0, 16)
|
|
}
|
|
|
|
function rulesJsonToText(rulesJson) {
|
|
if (!rulesJson) return '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}'
|
|
|
|
try {
|
|
return JSON.stringify(rulesJson, null, 2)
|
|
} catch {
|
|
return '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}'
|
|
}
|
|
}
|
|
|
|
function Field({ label, help, children }) {
|
|
return (
|
|
<label className="block space-y-2">
|
|
<span className="text-sm font-semibold text-white">{label}</span>
|
|
{children}
|
|
{help ? <span className="block text-xs leading-relaxed text-slate-400">{help}</span> : null}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
export default function CollectionStaffSurfaces() {
|
|
const { props } = usePage()
|
|
const collectionOptions = Array.isArray(props.collectionOptions) ? props.collectionOptions : []
|
|
const [definitions, setDefinitions] = React.useState(Array.isArray(props.definitions) ? props.definitions : [])
|
|
const [placements, setPlacements] = React.useState(Array.isArray(props.placements) ? props.placements : [])
|
|
const [conflicts, setConflicts] = React.useState(Array.isArray(props.conflicts) ? props.conflicts : [])
|
|
const [definitionForm, setDefinitionForm] = React.useState({
|
|
id: null,
|
|
surface_key: '',
|
|
title: '',
|
|
description: '',
|
|
mode: 'manual',
|
|
ranking_mode: 'ranking_score',
|
|
max_items: 12,
|
|
is_active: true,
|
|
starts_at: '',
|
|
ends_at: '',
|
|
fallback_surface_key: '',
|
|
rules_json: '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}',
|
|
})
|
|
const [placementForm, setPlacementForm] = React.useState({
|
|
id: null,
|
|
surface_key: props.surfaceKeyOptions?.[0] || '',
|
|
collection_id: collectionOptions[0]?.id || '',
|
|
placement_type: 'manual',
|
|
priority: 0,
|
|
starts_at: '',
|
|
ends_at: '',
|
|
is_active: true,
|
|
campaign_key: '',
|
|
notes: '',
|
|
})
|
|
const [batchForm, setBatchForm] = React.useState({
|
|
collection_ids: [],
|
|
campaign_key: '',
|
|
campaign_label: '',
|
|
event_label: '',
|
|
season_key: '',
|
|
editorial_notes: '',
|
|
surface_key: props.surfaceKeyOptions?.[0] || '',
|
|
placement_type: 'campaign',
|
|
priority: 0,
|
|
starts_at: '',
|
|
ends_at: '',
|
|
is_active: true,
|
|
notes: '',
|
|
})
|
|
const [batchResult, setBatchResult] = React.useState(null)
|
|
const [notice, setNotice] = React.useState('')
|
|
const [busy, setBusy] = React.useState('')
|
|
const seo = props.seo || {}
|
|
const surfaceKeyOptions = React.useMemo(() => {
|
|
const keys = definitions.map((definition) => definition.surface_key).filter(Boolean)
|
|
return Array.from(new Set(keys)).sort((left, right) => String(left).localeCompare(String(right)))
|
|
}, [definitions])
|
|
const conflictPlacementIds = React.useMemo(() => {
|
|
return new Set(conflicts.flatMap((conflict) => Array.isArray(conflict.placement_ids) ? conflict.placement_ids : []))
|
|
}, [conflicts])
|
|
|
|
React.useEffect(() => {
|
|
setPlacementForm((current) => {
|
|
if (current.surface_key && surfaceKeyOptions.includes(current.surface_key)) {
|
|
return current
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
surface_key: surfaceKeyOptions[0] || '',
|
|
}
|
|
})
|
|
|
|
setBatchForm((current) => {
|
|
if (!current.surface_key || surfaceKeyOptions.includes(current.surface_key)) {
|
|
return current
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
surface_key: surfaceKeyOptions[0] || '',
|
|
}
|
|
})
|
|
}, [surfaceKeyOptions])
|
|
|
|
function resetDefinitionForm() {
|
|
setDefinitionForm({
|
|
id: null,
|
|
surface_key: '',
|
|
title: '',
|
|
description: '',
|
|
mode: 'manual',
|
|
ranking_mode: 'ranking_score',
|
|
max_items: 12,
|
|
is_active: true,
|
|
starts_at: '',
|
|
ends_at: '',
|
|
fallback_surface_key: '',
|
|
rules_json: '{\n "campaign_key": "",\n "owner_username": "",\n "presentation_style": "hero_grid",\n "min_quality_score": 80\n}',
|
|
})
|
|
}
|
|
|
|
function resetPlacementForm() {
|
|
setPlacementForm({
|
|
id: null,
|
|
surface_key: surfaceKeyOptions[0] || '',
|
|
collection_id: collectionOptions[0]?.id || '',
|
|
placement_type: 'manual',
|
|
priority: 0,
|
|
starts_at: '',
|
|
ends_at: '',
|
|
is_active: true,
|
|
campaign_key: '',
|
|
notes: '',
|
|
})
|
|
}
|
|
|
|
function toggleBatchCollection(collectionId) {
|
|
setBatchForm((current) => {
|
|
const currentIds = Array.isArray(current.collection_ids) ? current.collection_ids : []
|
|
const nextIds = currentIds.includes(collectionId)
|
|
? currentIds.filter((id) => id !== collectionId)
|
|
: [...currentIds, collectionId]
|
|
|
|
return {
|
|
...current,
|
|
collection_ids: nextIds,
|
|
}
|
|
})
|
|
}
|
|
|
|
async function handleDefinitionSubmit(event) {
|
|
event.preventDefault()
|
|
setBusy('definition')
|
|
setNotice('')
|
|
|
|
try {
|
|
const rulesJson = definitionForm.rules_json.trim() ? JSON.parse(definitionForm.rules_json) : null
|
|
const url = definitionForm.id
|
|
? props.endpoints?.definitionsUpdatePattern?.replace('__DEFINITION__', String(definitionForm.id))
|
|
: props.endpoints?.definitionsStore
|
|
const payload = await requestJson(url, {
|
|
method: definitionForm.id ? 'PATCH' : 'POST',
|
|
body: {
|
|
...definitionForm,
|
|
max_items: Number(definitionForm.max_items || 12),
|
|
starts_at: definitionForm.starts_at ? new Date(definitionForm.starts_at).toISOString() : null,
|
|
ends_at: definitionForm.ends_at ? new Date(definitionForm.ends_at).toISOString() : null,
|
|
fallback_surface_key: definitionForm.fallback_surface_key || null,
|
|
rules_json: rulesJson,
|
|
},
|
|
})
|
|
|
|
setDefinitions((current) => {
|
|
const next = current.filter((definition) => definition.id !== payload.definition.id)
|
|
return [...next, payload.definition].sort((left, right) => String(left.surface_key).localeCompare(String(right.surface_key)))
|
|
})
|
|
setNotice(definitionForm.id ? 'Surface definition updated.' : 'Surface definition saved.')
|
|
resetDefinitionForm()
|
|
} catch (error) {
|
|
setNotice(error.message || 'Failed to save definition.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
async function handlePlacementSubmit(event) {
|
|
event.preventDefault()
|
|
setBusy('placement')
|
|
setNotice('')
|
|
|
|
try {
|
|
const url = placementForm.id
|
|
? props.endpoints?.placementsUpdatePattern?.replace('__PLACEMENT__', String(placementForm.id))
|
|
: props.endpoints?.placementsStore
|
|
const payload = await requestJson(url, {
|
|
method: placementForm.id ? 'PATCH' : 'POST',
|
|
body: {
|
|
...placementForm,
|
|
collection_id: Number(placementForm.collection_id),
|
|
priority: Number(placementForm.priority || 0),
|
|
starts_at: placementForm.starts_at ? new Date(placementForm.starts_at).toISOString() : null,
|
|
ends_at: placementForm.ends_at ? new Date(placementForm.ends_at).toISOString() : null,
|
|
},
|
|
})
|
|
|
|
setPlacements((current) => {
|
|
const next = current.filter((placement) => placement.id !== payload.placement.id)
|
|
return [...next, payload.placement].sort((left, right) => {
|
|
if (left.surface_key === right.surface_key) return (right.priority || 0) - (left.priority || 0)
|
|
return String(left.surface_key).localeCompare(String(right.surface_key))
|
|
})
|
|
})
|
|
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
|
|
setNotice(placementForm.id ? 'Surface placement updated.' : 'Surface placement saved.')
|
|
resetPlacementForm()
|
|
} catch (error) {
|
|
setNotice(error.message || 'Failed to save placement.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
async function handleBatchEditorial(mode) {
|
|
setBusy(`batch-${mode}`)
|
|
setNotice('')
|
|
|
|
try {
|
|
const payload = await requestJson(props.endpoints?.batchEditorial, {
|
|
method: 'POST',
|
|
body: {
|
|
...batchForm,
|
|
starts_at: batchForm.starts_at ? new Date(batchForm.starts_at).toISOString() : null,
|
|
ends_at: batchForm.ends_at ? new Date(batchForm.ends_at).toISOString() : null,
|
|
collection_ids: (batchForm.collection_ids || []).map((id) => Number(id)),
|
|
priority: Number(batchForm.priority || 0),
|
|
surface_key: batchForm.surface_key || null,
|
|
apply: mode === 'apply',
|
|
},
|
|
})
|
|
|
|
setBatchResult(payload.plan || null)
|
|
|
|
if (mode === 'apply') {
|
|
setPlacements(Array.isArray(payload.placements) ? payload.placements : [])
|
|
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
|
|
setNotice('Batch editorial changes applied.')
|
|
} else {
|
|
setNotice('Batch editorial preview generated.')
|
|
}
|
|
} catch (error) {
|
|
setNotice(error.message || 'Batch editorial tools failed.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
function hydrateDefinition(definition) {
|
|
setDefinitionForm({
|
|
id: definition.id,
|
|
surface_key: definition.surface_key || '',
|
|
title: definition.title || '',
|
|
description: definition.description || '',
|
|
mode: definition.mode || 'manual',
|
|
ranking_mode: definition.ranking_mode || 'ranking_score',
|
|
max_items: definition.max_items || 12,
|
|
is_active: definition.is_active !== false,
|
|
starts_at: isoToLocalInput(definition.starts_at),
|
|
ends_at: isoToLocalInput(definition.ends_at),
|
|
fallback_surface_key: definition.fallback_surface_key || '',
|
|
rules_json: rulesJsonToText(definition.rules_json),
|
|
})
|
|
}
|
|
|
|
function hydratePlacement(placement) {
|
|
setPlacementForm({
|
|
id: placement.id,
|
|
surface_key: placement.surface_key || '',
|
|
collection_id: placement.collection?.id || '',
|
|
placement_type: placement.placement_type || 'manual',
|
|
priority: placement.priority || 0,
|
|
starts_at: isoToLocalInput(placement.starts_at),
|
|
ends_at: isoToLocalInput(placement.ends_at),
|
|
is_active: placement.is_active !== false,
|
|
campaign_key: placement.campaign_key || '',
|
|
notes: placement.notes || '',
|
|
})
|
|
}
|
|
|
|
async function handleDeleteDefinition(definition) {
|
|
if (!window.confirm(`Delete surface definition "${definition.surface_key}"?`)) return
|
|
|
|
setBusy(`delete-definition-${definition.id}`)
|
|
setNotice('')
|
|
|
|
try {
|
|
const url = props.endpoints?.definitionsDeletePattern?.replace('__DEFINITION__', String(definition.id))
|
|
await requestJson(url, { method: 'DELETE' })
|
|
setDefinitions((current) => current.filter((item) => item.id !== definition.id))
|
|
if (definitionForm.id === definition.id) {
|
|
resetDefinitionForm()
|
|
}
|
|
setNotice('Surface definition deleted.')
|
|
} catch (error) {
|
|
setNotice(error.message || 'Failed to delete definition.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
async function handleDeletePlacement(placement) {
|
|
if (!window.confirm(`Delete placement for "${placement.collection?.title || 'this collection'}" on ${placement.surface_key}?`)) return
|
|
|
|
setBusy(`delete-placement-${placement.id}`)
|
|
setNotice('')
|
|
|
|
try {
|
|
const url = props.endpoints?.placementsDeletePattern?.replace('__PLACEMENT__', String(placement.id))
|
|
const payload = await requestJson(url, { method: 'DELETE' })
|
|
setPlacements((current) => current.filter((item) => item.id !== placement.id))
|
|
setConflicts(Array.isArray(payload.conflicts) ? payload.conflicts : [])
|
|
if (placementForm.id === placement.id) {
|
|
resetPlacementForm()
|
|
}
|
|
setNotice('Surface placement deleted.')
|
|
} catch (error) {
|
|
setNotice(error.message || 'Failed to delete placement.')
|
|
} finally {
|
|
setBusy('')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head>
|
|
<title>{seo.title || 'Collection Surfaces — Skinbase Nova'}</title>
|
|
<meta name="description" content={seo.description || 'Staff tools for collection surfaces.'} />
|
|
{seo.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
|
|
<meta name="robots" content={seo.robots || 'noindex,follow'} />
|
|
</Head>
|
|
|
|
<div className="relative min-h-screen overflow-hidden pb-16">
|
|
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[34rem] opacity-95" style={{ background: 'radial-gradient(circle at 15% 14%, rgba(245,158,11,0.16), transparent 26%), radial-gradient(circle at 82% 18%, rgba(56,189,248,0.16), transparent 24%), linear-gradient(180deg, #07101d 0%, #0a1220 42%, #08111f 100%)' }} />
|
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 opacity-[0.05]" style={{ backgroundImage: 'url(/gfx/noise.png)', backgroundSize: '180px' }} />
|
|
|
|
<div className="mx-auto max-w-7xl px-4 pt-8 md:px-6">
|
|
<section className="rounded-[34px] border border-white/10 bg-white/[0.04] p-6 shadow-[0_30px_90px_rgba(2,6,23,0.28)] backdrop-blur-sm md:p-8">
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Staff Surfaces</p>
|
|
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">Collections placement studio</h1>
|
|
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
|
|
Define reusable discovery surfaces, then place eligible public collections into manual or campaign-specific slots with clear timing and notes.
|
|
</p>
|
|
{notice ? <p className="mt-4 text-sm text-sky-100">{notice}</p> : null}
|
|
</section>
|
|
|
|
<section className="mt-8 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
|
<form onSubmit={handleDefinitionSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Surface Definition</p>
|
|
<h2 className="mt-2 text-2xl font-semibold text-white">Rules and ranking</h2>
|
|
</div>
|
|
{definitionForm.id ? <p className="mt-3 text-sm text-slate-300">Editing <span className="font-semibold text-white">{definitionForm.surface_key}</span></p> : null}
|
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
|
<Field label="Surface Key" help={definitionForm.id ? 'Surface keys stay stable during edits so existing placements remain attached.' : null}><input value={definitionForm.surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, surface_key: event.target.value }))} disabled={Boolean(definitionForm.id)} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none disabled:cursor-not-allowed disabled:opacity-60" maxLength={120} /></Field>
|
|
<Field label="Title"><input value={definitionForm.title} onChange={(event) => setDefinitionForm((current) => ({ ...current, title: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={160} /></Field>
|
|
<Field label="Mode"><select value={definitionForm.mode} onChange={(event) => setDefinitionForm((current) => ({ ...current, mode: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="manual">Manual</option><option value="automatic">Automatic</option><option value="hybrid">Hybrid</option></select></Field>
|
|
<Field label="Ranking"><select value={definitionForm.ranking_mode} onChange={(event) => setDefinitionForm((current) => ({ ...current, ranking_mode: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="ranking_score">Ranking score</option><option value="recent_activity">Recent activity</option><option value="quality_score">Quality score</option></select></Field>
|
|
<Field label="Max Items"><input type="number" min="1" max="24" value={definitionForm.max_items} onChange={(event) => setDefinitionForm((current) => ({ ...current, max_items: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
|
<Field label="Starts At" help="Optional activation window for the full surface definition."><input type="datetime-local" value={definitionForm.starts_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
|
<Field label="Ends At" help="Leave blank when the surface should stay live until staff changes it."><input type="datetime-local" value={definitionForm.ends_at} onChange={(event) => setDefinitionForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
|
<Field label="Fallback Surface Key" help="Optional fallback when this definition is inactive, scheduled out, or resolves no items."><input value={definitionForm.fallback_surface_key} onChange={(event) => setDefinitionForm((current) => ({ ...current, fallback_surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
|
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={definitionForm.is_active} onChange={(event) => setDefinitionForm((current) => ({ ...current, is_active: event.target.checked }))} />Active</label>
|
|
</div>
|
|
<Field label="Description" help="Operational note for staff browsing this surface later."><textarea value={definitionForm.description} onChange={(event) => setDefinitionForm((current) => ({ ...current, description: event.target.value }))} className="mt-4 min-h-[96px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={400} /></Field>
|
|
<Field label="Rules JSON" help="Supported filters include campaign, event, season, type, presentation_style, theme_token, collaboration_mode, owner_username or owner_usernames, commercial_eligible_only, analytics_enabled_only, min_quality_score, min_ranking_score, include_collection_ids, exclude_collection_ids, and featured_only."><textarea value={definitionForm.rules_json} onChange={(event) => setDefinitionForm((current) => ({ ...current, rules_json: event.target.value }))} className="mt-4 min-h-[160px] w-full rounded-2xl border border-white/10 bg-slate-950/50 px-4 py-3 font-mono text-sm text-white outline-none" spellCheck={false} /></Field>
|
|
<div className="mt-5 flex flex-wrap items-center gap-3">
|
|
<button type="submit" disabled={busy === 'definition'} className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'definition' ? 'fa-circle-notch fa-spin' : 'fa-layer-group'} fa-fw`} />{definitionForm.id ? 'Update Definition' : 'Save Definition'}</button>
|
|
{definitionForm.id ? <button type="button" onClick={resetDefinitionForm} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
|
|
</div>
|
|
</form>
|
|
|
|
<form onSubmit={handlePlacementSubmit} className="rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Surface Placement</p>
|
|
<h2 className="mt-2 text-2xl font-semibold text-white">Manual and campaign slots</h2>
|
|
</div>
|
|
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
|
<Field label="Surface"><select value={placementForm.surface_key} onChange={(event) => setPlacementForm((current) => ({ ...current, surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">{surfaceKeyOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select></Field>
|
|
<Field label="Collection"><select value={placementForm.collection_id} onChange={(event) => setPlacementForm((current) => ({ ...current, collection_id: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none">{collectionOptions.map((option) => <option key={option.id} value={option.id}>{option.title}</option>)}</select></Field>
|
|
<Field label="Placement Type"><select value={placementForm.placement_type} onChange={(event) => setPlacementForm((current) => ({ ...current, placement_type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="manual">Manual</option><option value="campaign">Campaign</option><option value="scheduled_override">Scheduled override</option></select></Field>
|
|
<Field label="Priority"><input type="number" min="-100" max="100" value={placementForm.priority} onChange={(event) => setPlacementForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
|
<Field label="Starts At"><input type="datetime-local" value={placementForm.starts_at} onChange={(event) => setPlacementForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
|
<Field label="Ends At"><input type="datetime-local" value={placementForm.ends_at} onChange={(event) => setPlacementForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
|
<Field label="Campaign Key" help="Optional campaign label for reporting and grouped overrides."><input value={placementForm.campaign_key} onChange={(event) => setPlacementForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={placementForm.is_active} onChange={(event) => setPlacementForm((current) => ({ ...current, is_active: event.target.checked }))} />Active placement</label>
|
|
</div>
|
|
<Field label="Notes" help="Internal note for why this collection owns the slot."><textarea value={placementForm.notes} onChange={(event) => setPlacementForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
|
|
<div className="mt-5 flex flex-wrap items-center gap-3">
|
|
<button type="submit" disabled={busy === 'placement'} className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'placement' ? 'fa-circle-notch fa-spin' : 'fa-thumbtack'} fa-fw`} />{placementForm.id ? 'Update Placement' : 'Save Placement'}</button>
|
|
{placementForm.id ? <button type="button" onClick={resetPlacementForm} className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-rotate-left fa-fw" />Cancel Edit</button> : null}
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-lime-200/80">Batch Editorial Tools</p>
|
|
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign planning in one pass</h2>
|
|
</div>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{batchForm.collection_ids.length} selected</span>
|
|
</div>
|
|
|
|
<div className="mt-6 grid gap-6 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
|
|
<div className="space-y-4">
|
|
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
|
<p className="text-sm font-semibold text-white">Choose collections</p>
|
|
<p className="mt-2 text-sm text-slate-300">The selector uses current public discovery candidates so staff can quickly prepare a seasonal or editorial run.</p>
|
|
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
|
{collectionOptions.map((option) => {
|
|
const checked = batchForm.collection_ids.includes(option.id)
|
|
return (
|
|
<label key={option.id} className={`flex cursor-pointer items-start gap-3 rounded-[22px] border px-4 py-3 transition ${checked ? 'border-lime-300/30 bg-lime-400/10' : 'border-white/10 bg-white/[0.04] hover:bg-white/[0.07]'}`}>
|
|
<input type="checkbox" checked={checked} onChange={() => toggleBatchCollection(option.id)} className="mt-1" />
|
|
<span className="min-w-0">
|
|
<span className="block truncate text-sm font-semibold text-white">{option.title}</span>
|
|
<span className="mt-1 block text-xs text-slate-400">{option.type || 'collection'} · {option.visibility || 'public'}</span>
|
|
</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
|
<p className="text-sm font-semibold text-white">Campaign metadata</p>
|
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
<Field label="Campaign Key"><input value={batchForm.campaign_key} onChange={(event) => setBatchForm((current) => ({ ...current, campaign_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
|
<Field label="Campaign Label"><input value={batchForm.campaign_label} onChange={(event) => setBatchForm((current) => ({ ...current, campaign_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
|
|
<Field label="Event Label"><input value={batchForm.event_label} onChange={(event) => setBatchForm((current) => ({ ...current, event_label: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={120} /></Field>
|
|
<Field label="Season Key"><input value={batchForm.season_key} onChange={(event) => setBatchForm((current) => ({ ...current, season_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={80} /></Field>
|
|
</div>
|
|
<Field label="Editorial Notes" help="Shared context recorded on each selected collection."><textarea value={batchForm.editorial_notes} onChange={(event) => setBatchForm((current) => ({ ...current, editorial_notes: event.target.value }))} className="mt-4 min-h-[120px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={4000} /></Field>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
|
<p className="text-sm font-semibold text-white">Optional placement plan</p>
|
|
<p className="mt-2 text-sm text-slate-300">If you set a surface, the preview shows which collections can safely be placed and which ones will be skipped.</p>
|
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
<Field label="Surface"><select value={batchForm.surface_key} onChange={(event) => setBatchForm((current) => ({ ...current, surface_key: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="">No placement</option>{surfaceKeyOptions.map((option) => <option key={option} value={option}>{option}</option>)}</select></Field>
|
|
<Field label="Placement Type"><select value={batchForm.placement_type} onChange={(event) => setBatchForm((current) => ({ ...current, placement_type: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none"><option value="campaign">Campaign</option><option value="manual">Manual</option><option value="scheduled_override">Scheduled override</option></select></Field>
|
|
<Field label="Priority"><input type="number" min="-100" max="100" value={batchForm.priority} onChange={(event) => setBatchForm((current) => ({ ...current, priority: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-white"><input type="checkbox" checked={batchForm.is_active} onChange={(event) => setBatchForm((current) => ({ ...current, is_active: event.target.checked }))} />Active placement</label>
|
|
<Field label="Starts At"><input type="datetime-local" value={batchForm.starts_at} onChange={(event) => setBatchForm((current) => ({ ...current, starts_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
|
<Field label="Ends At"><input type="datetime-local" value={batchForm.ends_at} onChange={(event) => setBatchForm((current) => ({ ...current, ends_at: event.target.value }))} className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" /></Field>
|
|
</div>
|
|
<Field label="Placement Notes"><textarea value={batchForm.notes} onChange={(event) => setBatchForm((current) => ({ ...current, notes: event.target.value }))} className="mt-4 min-h-[110px] w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none" maxLength={1000} /></Field>
|
|
<div className="mt-5 flex flex-wrap gap-3">
|
|
<button type="button" onClick={() => handleBatchEditorial('preview')} disabled={busy === 'batch-preview'} className="inline-flex items-center gap-2 rounded-2xl border border-lime-300/20 bg-lime-400/10 px-5 py-3 text-sm font-semibold text-lime-100 transition hover:bg-lime-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'batch-preview' ? 'fa-circle-notch fa-spin' : 'fa-flask'} fa-fw`} />Preview Batch</button>
|
|
<button type="button" onClick={() => handleBatchEditorial('apply')} disabled={busy === 'batch-apply'} className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === 'batch-apply' ? 'fa-circle-notch fa-spin' : 'fa-wand-magic-sparkles'} fa-fw`} />Apply Batch</button>
|
|
</div>
|
|
</div>
|
|
|
|
{batchResult ? (
|
|
<div className="rounded-[26px] border border-white/10 bg-slate-950/40 p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-semibold text-white">Preview results</p>
|
|
<p className="mt-1 text-sm text-slate-300">{batchResult.collections_count} collections reviewed, {batchResult.placement_eligible_count} placement-ready.</p>
|
|
</div>
|
|
</div>
|
|
<div className="mt-4 space-y-3">
|
|
{(batchResult.items || []).map((item) => (
|
|
<div key={item.collection?.id} className="rounded-[22px] border border-white/10 bg-white/[0.04] p-4">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-sm font-semibold text-white">{item.collection?.title}</p>
|
|
<p className="mt-1 text-xs text-slate-400">{item.collection?.visibility} · {item.collection?.lifecycle_state} · {item.collection?.moderation_status}</p>
|
|
</div>
|
|
{item.placement ? (
|
|
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] ${item.placement.eligible ? 'border-lime-300/20 bg-lime-400/10 text-lime-100' : 'border-rose-300/20 bg-rose-400/10 text-rose-100'}`}>
|
|
{item.placement.eligible ? `ready for ${item.placement.surface_key}` : 'placement skipped'}
|
|
</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-slate-300">metadata only</span>}
|
|
</div>
|
|
{item.eligibility?.reasons?.length ? <p className="mt-3 text-xs text-amber-100/80">Campaign readiness: {item.eligibility.reasons.join(' ')}</p> : null}
|
|
{item.placement?.reasons?.length ? <p className="mt-2 text-xs text-rose-100/80">Placement: {item.placement.reasons.join(' ')}</p> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Definitions</p>
|
|
<h2 className="mt-2 text-2xl font-semibold text-white">Registered surfaces</h2>
|
|
</div>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{definitions.length}</span>
|
|
</div>
|
|
|
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
|
{definitions.map((definition) => (
|
|
<div key={definition.id} className="rounded-[24px] border border-white/10 bg-slate-950/40 p-5">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">{definition.surface_key}</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-slate-300">{definition.mode}</span>
|
|
</div>
|
|
<h3 className="mt-4 text-lg font-semibold text-white">{definition.title}</h3>
|
|
{definition.description ? <p className="mt-2 text-sm text-slate-300">{definition.description}</p> : null}
|
|
<div className="mt-4 flex flex-wrap gap-2 text-xs text-slate-400">
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{definition.ranking_mode}</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">max {definition.max_items}</span>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">{definition.is_active ? 'active' : 'inactive'}</span>
|
|
{definition.starts_at ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">starts {new Date(definition.starts_at).toLocaleString()}</span> : null}
|
|
{definition.ends_at ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">ends {new Date(definition.ends_at).toLocaleString()}</span> : null}
|
|
{definition.fallback_surface_key ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">fallback {definition.fallback_surface_key}</span> : null}
|
|
</div>
|
|
<div className="mt-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
<button type="button" onClick={() => hydrateDefinition(definition)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit Definition</button>
|
|
<button type="button" onClick={() => handleDeleteDefinition(definition)} disabled={busy === `delete-definition-${definition.id}`} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `delete-definition-${definition.id}` ? 'fa-circle-notch fa-spin' : 'fa-trash'} fa-fw text-[10px]`} />Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
{conflicts.length ? (
|
|
<section className="mt-8 rounded-[32px] border border-rose-300/20 bg-rose-500/10 p-6 backdrop-blur-sm md:p-7">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-rose-100/80">Conflicts</p>
|
|
<h2 className="mt-2 text-2xl font-semibold text-white">Schedule overlaps need review</h2>
|
|
</div>
|
|
<span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-xs font-semibold text-rose-100">{conflicts.length}</span>
|
|
</div>
|
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
|
{conflicts.map((conflict, index) => (
|
|
<div key={`${conflict.surface_key}-${index}`} className="rounded-[24px] border border-rose-300/20 bg-slate-950/40 p-5">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100">{conflict.surface_key}</span>
|
|
</div>
|
|
<p className="mt-4 text-sm text-rose-50">{conflict.summary}</p>
|
|
<p className="mt-3 text-xs text-rose-100/70">
|
|
Window: {conflict.window?.starts_at ? new Date(conflict.window.starts_at).toLocaleString() : 'Immediate'} to {conflict.window?.ends_at ? new Date(conflict.window.ends_at).toLocaleString() : 'Open-ended'}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
|
|
<section className="mt-8 rounded-[32px] border border-white/10 bg-white/[0.04] p-6 backdrop-blur-sm md:p-7">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-amber-200/80">Placements</p>
|
|
<h2 className="mt-2 text-2xl font-semibold text-white">Active and scheduled slots</h2>
|
|
</div>
|
|
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-xs font-semibold text-slate-300">{placements.length}</span>
|
|
</div>
|
|
|
|
<div className="mt-6 space-y-5">
|
|
{placements.map((placement) => (
|
|
<div key={placement.id} className="rounded-[28px] border border-white/10 bg-slate-950/40 p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-amber-100">{placement.surface_key}</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-slate-300">{placement.placement_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-slate-300">priority {placement.priority}</span>
|
|
{conflictPlacementIds.has(placement.id) || placement.has_conflict ? <span className="rounded-full border border-rose-300/20 bg-rose-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-rose-100">conflict</span> : null}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button type="button" onClick={() => hydratePlacement(placement)} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-xs font-semibold text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-pen fa-fw text-[10px]" />Edit</button>
|
|
<button type="button" onClick={() => handleDeletePlacement(placement)} disabled={busy === `delete-placement-${placement.id}`} className="inline-flex items-center gap-2 rounded-full border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-xs font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:opacity-60"><i className={`fa-solid ${busy === `delete-placement-${placement.id}` ? 'fa-circle-notch fa-spin' : 'fa-trash'} fa-fw text-[10px]`} />Delete</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid gap-5 xl:grid-cols-[minmax(0,1fr)_280px]">
|
|
<div>
|
|
{placement.collection ? <CollectionCard collection={placement.collection} isOwner /> : null}
|
|
</div>
|
|
<div className="space-y-3 text-sm text-slate-300">
|
|
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Starts: {placement.starts_at ? new Date(placement.starts_at).toLocaleString() : 'Immediate'}</div>
|
|
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Ends: {placement.ends_at ? new Date(placement.ends_at).toLocaleString() : 'Open-ended'}</div>
|
|
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Campaign: {placement.campaign_key || 'None'}</div>
|
|
<div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3">Status: {placement.is_active ? 'Active' : 'Inactive'}</div>
|
|
{placement.notes ? <div className="rounded-[22px] border border-white/10 bg-white/[0.04] px-4 py-3 text-slate-300">{placement.notes}</div> : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|