Files
SkinbaseNova/resources/js/Pages/Collection/CollectionFeaturedIndex.jsx
2026-03-28 19:15:39 +01:00

490 lines
27 KiB
JavaScript

import React from 'react'
import { Head, usePage } from '@inertiajs/react'
import CollectionCard from '../../components/profile/collections/CollectionCard'
const SEARCH_SELECT_OPTIONS = {
type: [
{ value: 'personal', label: 'Personal' },
{ value: 'community', label: 'Community' },
{ value: 'editorial', label: 'Editorial' },
],
mode: [
{ value: 'manual', label: 'Manual' },
{ value: 'smart', label: 'Smart' },
],
lifecycle_state: [
{ value: 'published', label: 'Published' },
{ value: 'featured', label: 'Featured' },
{ value: 'archived', label: 'Archived' },
],
health_state: [
{ value: 'healthy', label: 'Healthy' },
{ value: 'needs_metadata', label: 'Needs metadata' },
{ value: 'stale', label: 'Stale' },
{ value: 'low_content', label: 'Low content' },
{ value: 'broken_items', label: 'Broken items' },
{ value: 'weak_cover', label: 'Weak cover' },
{ value: 'low_engagement', label: 'Low engagement' },
{ value: 'duplicate_risk', label: 'Duplicate risk' },
{ value: 'merge_candidate', label: 'Merge candidate' },
],
sort: [
{ value: 'trending', label: 'Trending' },
{ value: 'recent', label: 'Recent' },
{ value: 'quality', label: 'Quality' },
{ value: 'evergreen', label: 'Evergreen' },
],
}
function humanizeToken(value) {
return String(value || '')
.replaceAll('_', ' ')
.replaceAll('-', ' ')
.replace(/\b\w/g, (match) => match.toUpperCase())
}
function searchChipLabel(key, value) {
if (!value) return null
const option = SEARCH_SELECT_OPTIONS[key]?.find((item) => item.value === value)
const displayValue = option?.label || humanizeToken(value)
return key === 'q'
? `Query: ${value}`
: key === 'campaign_key'
? `Campaign: ${displayValue}`
: key === 'program_key'
? `Program: ${displayValue}`
: key === 'quality_tier'
? `Quality Tier: ${displayValue}`
: key === 'sort'
? `Sort: ${displayValue}`
: `${humanizeToken(key)}: ${displayValue}`
}
function buildSearchHref(filters, omitKey = null) {
const params = new URLSearchParams()
Object.entries(filters || {}).forEach(([key, value]) => {
if (key === omitKey) return
if (value === null || value === undefined || value === '') return
params.set(key, value)
})
const query = params.toString()
return query ? `/collections/search?${query}` : '/collections/search'
}
function activeSearchChips(filters) {
return Object.entries(filters || {})
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => ({
key,
label: searchChipLabel(key, value),
href: buildSearchHref(filters, key),
}))
.filter((chip) => chip.label)
}
function primarySaveContext({ search, campaign, program, title, eyebrow }) {
if (search) {
return {
context: 'collection_search',
meta: {
query: search?.filters?.q || null,
surface_label: 'collection search',
},
}
}
if (campaign) {
return {
context: 'campaign_landing',
meta: {
campaign_key: campaign.key,
campaign_label: campaign.label,
surface_label: campaign.label || 'campaign landing',
},
}
}
if (program) {
return {
context: 'program_landing',
meta: {
program_key: program.key,
program_label: program.label,
surface_label: program.label || 'program landing',
},
}
}
if (eyebrow === 'Trending') return { context: 'trending_landing', meta: { surface_label: 'trending collections' } }
if (eyebrow === 'Editorial') return { context: 'editorial_landing', meta: { surface_label: 'editorial collections' } }
if (eyebrow === 'Community') return { context: 'community_landing', meta: { surface_label: 'community collections' } }
if (eyebrow === 'Seasonal') return { context: 'seasonal_landing', meta: { surface_label: 'seasonal collections' } }
if (title === 'Recommended collections' || title === 'Collections worth exploring') return { context: 'recommended_landing', meta: { surface_label: 'recommended collections' } }
return {
context: 'featured_landing',
meta: { surface_label: 'featured collections' },
}
}
function HeroStat({ icon, label, value }) {
return (
<div className="rounded-[22px] border border-white/10 bg-white/[0.05] px-4 py-4">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">
<i className={`fa-solid ${icon} text-[10px]`} />
{label}
</div>
<div className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-white">{value}</div>
</div>
)
}
function EmptyState() {
return (
<div className="rounded-[32px] border border-dashed border-white/12 bg-white/[0.03] px-6 py-16 text-center">
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-[24px] border border-white/12 bg-white/[0.05] text-slate-400">
<i className="fa-solid fa-compass text-3xl" />
</div>
<h2 className="mt-5 text-2xl font-semibold text-white">No featured collections yet</h2>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-300">
Featured placement is reserved for public collections with a strong visual point of view. Check back once creators start pinning their best showcases.
</p>
<div className="mt-6 flex justify-center">
<a
href="/"
className="inline-flex items-center gap-2 rounded-2xl border border-white/12 bg-white/[0.05] px-5 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]"
>
<i className="fa-solid fa-house fa-fw" />
Back to home
</a>
</div>
</div>
)
}
function SearchPanel({ search }) {
if (!search) return null
const filters = search.filters || {}
const options = search.options || {}
const chips = activeSearchChips(filters)
return (
<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">Filters</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Search collections</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">{search?.meta?.total ?? 0} results</span>
</div>
<form method="GET" action="/collections/search" className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<input name="q" defaultValue={filters.q || ''} placeholder="Search title or summary" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35 xl:col-span-2" />
<select name="type" defaultValue={filters.type || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">All types</option>
<option value="personal">Personal</option>
<option value="community">Community</option>
<option value="editorial">Editorial</option>
</select>
<select name="sort" defaultValue={filters.sort || 'trending'} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="trending">Trending</option>
<option value="recent">Recent</option>
<option value="quality">Quality</option>
<option value="evergreen">Evergreen</option>
</select>
<select name="category" defaultValue={filters.category || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any category</option>
{(options.category || []).map((item) => (
<option key={`category-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<select name="mode" defaultValue={filters.mode || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any curation mode</option>
<option value="manual">Manual</option>
<option value="smart">Smart</option>
</select>
<select name="style" defaultValue={filters.style || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any style signal</option>
{(options.style || []).map((item) => (
<option key={`style-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<select name="lifecycle_state" defaultValue={filters.lifecycle_state || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any lifecycle</option>
<option value="published">Published</option>
<option value="featured">Featured</option>
<option value="archived">Archived</option>
</select>
<select name="theme" defaultValue={filters.theme || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any theme</option>
{(options.theme || []).map((item) => (
<option key={`theme-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<select name="health_state" defaultValue={filters.health_state || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any quality state</option>
<option value="healthy">Healthy</option>
<option value="needs_metadata">Needs metadata</option>
<option value="stale">Stale</option>
<option value="low_content">Low content</option>
<option value="broken_items">Broken items</option>
<option value="weak_cover">Weak cover</option>
<option value="low_engagement">Low engagement</option>
<option value="duplicate_risk">Duplicate risk</option>
<option value="merge_candidate">Merge candidate</option>
</select>
<select name="color" defaultValue={filters.color || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any color palette</option>
{(options.color || []).map((item) => (
<option key={`color-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<input name="campaign_key" defaultValue={filters.campaign_key || ''} placeholder="Campaign key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
<input name="program_key" defaultValue={filters.program_key || ''} placeholder="Program key" className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35" />
<select name="quality_tier" defaultValue={filters.quality_tier || ''} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/35">
<option value="">Any quality tier</option>
{(options.quality_tier || []).map((item) => (
<option key={`quality-tier-${item.value}`} value={item.value}>{item.label}</option>
))}
</select>
<div className="md:col-span-2 xl:col-span-4 flex flex-wrap gap-3">
<button type="submit" 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"><i className="fa-solid fa-magnifying-glass fa-fw" />Apply filters</button>
<a href="/collections/search" 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" />Reset</a>
</div>
</form>
{chips.length ? (
<div className="mt-5 flex flex-wrap gap-3">
{chips.map((chip) => (
<a key={chip.key} href={chip.href} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-xs font-semibold text-slate-200 transition hover:bg-white/[0.08]">
<span>{chip.label}</span>
<i className="fa-solid fa-xmark text-[10px] text-slate-400" />
</a>
))}
</div>
) : null}
{(search?.links?.prev || search?.links?.next) ? (
<div className="mt-5 flex flex-wrap gap-3 text-sm">
{search.links.prev ? <a href={search.links.prev} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white transition hover:bg-white/[0.07]"><i className="fa-solid fa-arrow-left fa-fw text-[10px]" />Previous</a> : null}
{search.links.next ? <a href={search.links.next} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-white transition hover:bg-white/[0.07]">Next<i className="fa-solid fa-arrow-right fa-fw text-[10px]" /></a> : null}
</div>
) : null}
</section>
)
}
export default function CollectionFeaturedIndex() {
const { props } = usePage()
const seo = props.seo || {}
const eyebrow = props.eyebrow || 'Discovery'
const title = props.title || 'Featured collections'
const description = props.description || 'A rotating set of standout galleries from across Skinbase Nova. Some are meticulously hand-sequenced. Others are smart collections that stay fresh as the creator publishes new work.'
const collections = Array.isArray(props.collections) ? props.collections : []
const communityCollections = Array.isArray(props.communityCollections) ? props.communityCollections : []
const editorialCollections = Array.isArray(props.editorialCollections) ? props.editorialCollections : []
const recentCollections = Array.isArray(props.recentCollections) ? props.recentCollections : []
const trendingCollections = Array.isArray(props.trendingCollections) ? props.trendingCollections : []
const seasonalCollections = Array.isArray(props.seasonalCollections) ? props.seasonalCollections : []
const campaign = props.campaign || null
const program = props.program || null
const search = props.search || null
const smartCount = collections.filter((collection) => collection?.mode === 'smart').length
const totalArtworks = collections.reduce((sum, collection) => sum + (collection?.artworks_count || 0), 0)
const mainSave = primarySaveContext({ search, campaign, program, title, eyebrow })
const listSchema = seo?.canonical ? {
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: title,
description,
url: seo.canonical,
mainEntity: {
'@type': 'ItemList',
numberOfItems: collections.length,
itemListElement: collections.slice(0, 18).map((collection, index) => ({
'@type': 'ListItem',
position: index + 1,
url: collection.url,
name: collection.title,
})),
},
} : null
return (
<>
<Head>
<title>{seo?.title || `${title} — Skinbase Nova`}</title>
<meta name="description" content={seo?.description || description} />
{seo?.canonical ? <link rel="canonical" href={seo.canonical} /> : null}
<meta name="robots" content={seo?.robots || 'index,follow'} />
<meta property="og:title" content={seo?.title || `${title} — Skinbase Nova`} />
<meta property="og:description" content={seo?.description || description} />
{seo?.canonical ? <meta property="og:url" content={seo.canonical} /> : null}
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={seo?.title || `${title} — Skinbase Nova`} />
<meta name="twitter:description" content={seo?.description || description} />
{listSchema ? <script type="application/ld+json">{JSON.stringify(listSchema)}</script> : null}
</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-[38rem] opacity-95"
style={{
background: 'radial-gradient(circle at 12% 14%, rgba(56,189,248,0.18), transparent 28%), radial-gradient(circle at 88% 16%, rgba(249,115,22,0.18), transparent 26%), 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">
<div className="flex flex-wrap items-center gap-3 text-sm text-slate-300">
<a href="/" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">
<i className="fa-solid fa-arrow-left fa-fw text-[11px]" />
Back to home
</a>
<a href="/collections/featured" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Featured</a>
<a href="/collections/trending" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Trending</a>
<a href="/collections/community" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Community</a>
<a href="/collections/editorial" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Editorial</a>
<a href="/collections/seasonal" className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 transition hover:bg-white/[0.07] hover:text-white">Seasonal</a>
</div>
<section className="mt-6 overflow-hidden 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">
<div className="grid gap-8 xl:grid-cols-[minmax(0,1.18fr)_400px] xl:items-end">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">{eyebrow}</p>
<h1 className="mt-3 text-4xl font-semibold tracking-[-0.05em] text-white md:text-5xl">{title}</h1>
{campaign?.badge_label ? (
<div className="mt-4 inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
{campaign.badge_label}
</div>
) : program?.promotion_tier ? (
<div className="mt-4 inline-flex items-center rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.18em] text-sky-100">
Promotion tier: {program.promotion_tier}
</div>
) : null}
<p className="mt-4 max-w-3xl text-sm leading-relaxed text-slate-300 md:text-[15px]">
{description}
</p>
{campaign ? (
<div className="mt-5 flex flex-wrap gap-3 text-xs text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Campaign key: {campaign.key}</span>
{campaign.event_label ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Event: {campaign.event_label}</span> : null}
{campaign.season_key ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Season: {campaign.season_key}</span> : null}
{Array.isArray(campaign.active_surface_keys) && campaign.active_surface_keys.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Surfaces: {campaign.active_surface_keys.join(', ')}</span> : null}
</div>
) : program ? (
<div className="mt-5 flex flex-wrap gap-3 text-xs text-slate-300">
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Program key: {program.key}</span>
<span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Collections: {program.collections_count ?? collections.length}</span>
{program.trust_tier ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Trust: {program.trust_tier}</span> : null}
{Array.isArray(program.partner_labels) && program.partner_labels.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Partners: {program.partner_labels.join(', ')}</span> : null}
{Array.isArray(program.sponsorship_labels) && program.sponsorship_labels.length ? <span className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-2">Sponsors: {program.sponsorship_labels.join(', ')}</span> : null}
</div>
) : null}
</div>
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
<HeroStat icon="fa-layer-group" label="Collections" value={collections.length.toLocaleString()} />
<HeroStat icon="fa-wand-magic-sparkles" label="Smart" value={smartCount.toLocaleString()} />
<HeroStat icon="fa-images" label="Artworks" value={totalArtworks.toLocaleString()} />
</div>
</div>
</section>
<section className="mt-8">
<SearchPanel search={search} />
</section>
<section className="mt-8">
{collections.length ? (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{collections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext={mainSave.context} saveContextMeta={mainSave.meta} />
))}
</div>
) : (
<EmptyState />
)}
</section>
{communityCollections.length ? (
<section className="mt-10">
<div className="flex items-end justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Community</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Collaborative picks</h2>
</div>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{communityCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="community_row" saveContextMeta={{ surface_label: 'community collections' }} />
))}
</div>
</section>
) : null}
{trendingCollections.length ? (
<section className="mt-10">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Trending</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Momentum right now</h2>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{trendingCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="trending_row" saveContextMeta={{ surface_label: 'trending collections' }} />
))}
</div>
</section>
) : null}
{editorialCollections.length ? (
<section className="mt-10">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Editorial</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Staff and campaign collections</h2>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{editorialCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="editorial_row" saveContextMeta={{ surface_label: 'editorial collections' }} />
))}
</div>
</section>
) : null}
{seasonalCollections.length ? (
<section className="mt-10">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Seasonal</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Campaign and event spotlights</h2>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{seasonalCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="seasonal_row" saveContextMeta={{ surface_label: 'seasonal collections' }} />
))}
</div>
</section>
) : null}
{recentCollections.length ? (
<section className="mt-10 pb-8">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80">Recent</p>
<h2 className="mt-2 text-2xl font-semibold text-white">Freshly published collections</h2>
</div>
<div className="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2 xl:grid-cols-3">
{recentCollections.map((collection) => (
<CollectionCard key={collection.id} collection={collection} isOwner={false} saveContext="recent_row" saveContextMeta={{ surface_label: 'recent collections' }} />
))}
</div>
</section>
) : null}
</div>
</div>
</>
)
}