// SONARA — Producer Opportunità (feed richieste open + filter sidebar Airbnb-style)
const OpIcons = window.SonaraIcons;
/* ICONS */
const ChevIc = ({ size = 12 }) => ;
const StarIc = ({ size = 12, filled = true }) => ;
const ClockIc = ({ size = 12 }) => ;
const PinIc = ({ size = 12 }) => ;
const PaperIc = ({ size = 12 }) => ;
const UsersIc = ({ size = 12 }) => ;
const SearchIc = ({ size = 14 }) => ;
const PlayIc = ({ size = 11 }) => ;
const PaperclipIc = ({ size = 12 }) => ;
const BoltIc = ({ size = 11 }) => ;
const XIc = ({ size = 12 }) => ;
const BookmarkIc = ({ size = 13 }) => ;
const BellIc = ({ size = 12 }) => ;
const NICO_GENRES = ["Trap", "Drill", "Hip-hop", "R&B"];
const REQS = [
{
id: "REQ-9214", kind: "Beat custom",
title: "Cerco beat trap 808 pesante stile Travis Scott late-night",
brief: "Sto lavorando ad un singolo per uscita estate. Cerco un beat trap con vibe notturna, 808 spaziali, BPM 138-145, key minore. Ref: Travis Scott — \"After Hours\", Drake — \"Headlines\" (cassa più tirata).\n\nDeve essere originale, non type beat caricato online. Mi piacerebbe collaborare a lungo termine se il primo round va bene.",
artist: "AlessioRap", artistFull: "Alessio Marchetti", artistInit: "AR", verified: true,
city: "Milano", country: "IT", rating: 4.7, pastProjects: 3,
budget: { min: 100, max: 200 }, deadlineDays: 10, offers: 4,
posted: "2h fa", expiresIn: "28gg", postedHours: 2,
matchTags: ["Trap", "BPM 138-145"], genres: ["Trap"], bpm: 142, mkey: "F# min",
attachments: [{ name: "ref-travis.mp3", size: "3.2 MB" }, { name: "topline-rough.m4a", size: "1.8 MB" }],
new: true, briefComplete: true,
},
{
id: "REQ-9210", kind: "Mix + Master",
title: "Mix EP 5 tracce drill — stems pronti",
brief: "EP completo 5 tracce drill UK-influence, già recorded. Stems puliti (vox + 808 + drums + melodie). Cerco mix radio-ready + master.",
artist: "Quartiere9", artistFull: "Davide Romano", artistInit: "Q9", verified: true,
city: "Roma", country: "IT", rating: 4.9, pastProjects: 12,
budget: { min: 600, max: 900 }, deadlineDays: 21, offers: 7,
posted: "5h fa", expiresIn: "27gg", postedHours: 5,
matchTags: ["Drill", "Mix + Master"], genres: ["Drill"], briefComplete: true,
},
{
id: "REQ-9203", kind: "Custom production",
title: "Brano R&B con vibe Daniel Caesar / Brent Faiyaz",
brief: "Beat + mix base per singolo R&B. Cerco mood notturno, chitarre arpeggiate, drum break leggero.",
artist: "Lina Sky", artistFull: "Lina Sky", artistInit: "LS", verified: false,
city: "Bologna", country: "IT", rating: null, pastProjects: 0,
budget: { min: 400, max: 400, fixed: true }, deadlineDays: 7, offers: 2,
posted: "Ieri", expiresIn: "23gg", postedHours: 26,
matchTags: ["R&B"], genres: ["R&B"], new: true, briefComplete: false,
},
{
id: "REQ-9198", kind: "Vocal production",
title: "Vocal production + tuning su 1 brano trap melodico",
brief: "Ho registrato voce e doppie ma serve tuning fine, comping, ad-lib di rinforzo e processing finale prima del mix.",
artist: "DamoBeats", artistFull: "Damiano B.", artistInit: "DB", verified: true,
city: "Milano", country: "IT", rating: 4.5, pastProjects: 2,
budget: { min: 80, max: 150 }, deadlineDays: 4, offers: 3,
posted: "2gg fa", expiresIn: "22gg", postedHours: 50,
matchTags: ["Trap", "Vocal prod"], genres: ["Trap"], briefComplete: true,
},
{
id: "REQ-9192", kind: "Beat custom",
title: "Beat hip-hop boom bap con campioni vinilici",
brief: "Cerco beat boom bap old-school, 90-95 BPM. Devono esserci sample vinilici puliti (no copyright). Per un EP indipendente.",
artist: "Rino82", artistFull: "Riccardo N.", artistInit: "R8", verified: false,
city: "Torino", country: "IT", rating: 4.2, pastProjects: 1,
budget: { min: 150, max: 300 }, deadlineDays: 15, offers: 5,
posted: "3gg fa", expiresIn: "20gg", postedHours: 74,
matchTags: ["Hip-hop"], genres: ["Hip-hop"], bpm: 92, briefComplete: true,
},
{
id: "REQ-9185", kind: "Mastering",
title: "Master per singolo trap già missato",
brief: "Mix già pronto, cerco master loud + adattamento Spotify + WAV/MP3 finali. 48h se possibile.",
artist: "Yoshi K.", artistFull: "Yoshi K.", artistInit: "YK", verified: true,
city: "Milano", country: "IT", rating: 4.8, pastProjects: 5,
budget: { min: 70, max: 100 }, deadlineDays: 2, offers: 6,
posted: "4gg fa", expiresIn: "16gg", postedHours: 98,
matchTags: ["Trap", "Master"], genres: ["Trap"], briefComplete: true,
},
// Extra forYou — variety
{
id: "REQ-9176", kind: "Beat custom",
title: "Beat drill UK 140 BPM scuro con flauti",
brief: "Per featuring tra 2 artisti, vibe Central Cee / Headie One. Flauti dark, 808 scivolosi, switch alla seconda strofa.",
artist: "Jaxon", artistFull: "Jaxon", artistInit: "JX", verified: true,
city: "Milano", country: "IT", rating: 4.6, pastProjects: 4,
budget: { min: 250, max: 450 }, deadlineDays: 8, offers: 9,
posted: "1gg fa", expiresIn: "21gg", postedHours: 28,
matchTags: ["Drill", "BPM 140"], genres: ["Drill"], bpm: 140, mkey: "C min", briefComplete: true,
},
{
id: "REQ-9170", kind: "Mix stereo",
title: "Mix singolo trap melodico — premium",
brief: "Singolo solista, 808 puliti, voce in primo piano, autotune leggero. Cerco mix radio-ready stile Lazza/Sfera.",
artist: "ElenaR.", artistFull: "Elena Rovera", artistInit: "ER", verified: true,
city: "Napoli", country: "IT", rating: 4.9, pastProjects: 18,
budget: { min: 180, max: 280 }, deadlineDays: 12, offers: 11,
posted: "2gg fa", expiresIn: "20gg", postedHours: 52,
matchTags: ["Trap", "Mix"], genres: ["Trap"], briefComplete: true,
},
// Out-of-match — only in "Tutte"
{
id: "REQ-9181", kind: "Songwriting",
title: "Topline + testo per brano indie-pop italiano",
brief: "Beat indie-pop pronto, cerco topliner per melodia + testo (italiano). Vibe Coma_Cose / Maneskin ballad.",
artist: "Olivia M.", artistFull: "Olivia Marletti", artistInit: "OM", verified: false,
city: "Firenze", country: "IT", rating: null, pastProjects: 0,
budget: { min: 200, max: 400 }, deadlineDays: 14, offers: 2,
posted: "5gg fa", expiresIn: "15gg", postedHours: 122,
matchTags: ["Indie-pop"], genres: ["Indie-pop"], outOfMatch: true, briefComplete: false,
},
{
id: "REQ-9174", kind: "Sound design",
title: "Sound design per podcast true-crime",
brief: "8 puntate, mi serve sigla + transizioni + atmo dark/tensione. Stile Serial / Indagini.",
artist: "Studio Onda", artistFull: "Studio Onda", artistInit: "SO", verified: true,
city: "Roma", country: "IT", rating: 4.6, pastProjects: 9,
budget: { min: 800, max: 1500 }, deadlineDays: 30, offers: 11,
posted: "1 sett. fa", expiresIn: "13gg", postedHours: 168,
matchTags: ["Sound design"], genres: ["Cinematic"], outOfMatch: true, briefComplete: true,
},
{
id: "REQ-9168", kind: "Mix + Master",
title: "Mix album cantautorato acustico — 9 tracce",
brief: "Album cantautorato registrato in analogico, stems puliti. Cerco mix che mantenga il sapore caldo dei nastri.",
artist: "Pietro V.", artistFull: "Pietro Vannucci", artistInit: "PV", verified: true,
city: "Verona", country: "IT", rating: 4.9, pastProjects: 7,
budget: { min: 900, max: 1400 }, deadlineDays: 28, offers: 8,
posted: "1 sett. fa", expiresIn: "13gg", postedHours: 170,
matchTags: ["Cantautorato"], genres: ["Cantautorato"], outOfMatch: true, briefComplete: true,
},
{
id: "REQ-9160", kind: "Beat custom",
title: "Beat techno/house 124 BPM per DJ set",
brief: "Producer techno emergente, cerco beat originale per chiusura set. 124 BPM, dark, build lungo.",
artist: "K-Line", artistFull: "K-Line", artistInit: "KL", verified: false,
city: "Berlino", country: "DE", rating: 4.3, pastProjects: 2,
budget: { min: 300, max: 500 }, deadlineDays: 12, offers: 14,
posted: "1 sett. fa", expiresIn: "10gg", postedHours: 175,
matchTags: ["Techno"], genres: ["Elettronica"], bpm: 124, outOfMatch: true, briefComplete: true,
},
];
const SAVED_SEARCHES = [
{ id: "ss1", name: "Trap Milano premium", desc: "Trap · Milano · €200+ · <5 offerte", count: 3, alert: "instant" },
{ id: "ss2", name: "Mix EP completo", desc: "Mix+Master · €500+ · 14gg+", count: 8, alert: "daily" },
{ id: "ss3", name: "Custom R&B", desc: "Custom · R&B · €300+", count: 1, alert: "weekly" },
];
const ALL_GENRES = ["Trap", "Drill", "Hip-hop", "R&B", "Pop", "Indie-pop", "Cantautorato", "Elettronica", "Cinematic", "Jazz", "Lofi", "Rock"];
const ALL_TYPES = ["Beat custom", "Mix stereo", "Mastering", "Mix + Master", "Vocal production", "Custom production", "Sound design", "Songwriting"];
const ALL_KEYS = ["C", "C# / Db", "D", "D# / Eb", "E", "F", "F# / Gb", "G", "G# / Ab", "A", "A# / Bb", "B"];
const DEFAULT_FILTERS = {
search: "",
types: [],
genres: [],
budget: [0, 2000],
geo: "italy", // italy | europe | world | near
radius: 50,
offers: "all", // all | lt3 | mid | high
deadline: "all", // all | rush | week2 | week4 | longer
verifiedOnly: false,
briefCompleteOnly: false,
noOffersOnly: false,
bpmRange: [60, 200],
keys: [],
};
/* ──────────────────────────────────────────────────────── */
/* PRIMITIVES */
/* ──────────────────────────────────────────────────────── */
function FilterSection({ title, badge, children, defaultOpen = true }) {
const [open, setOpen] = React.useState(defaultOpen);
return (
{open &&
{children}
}
);
}
function PillRow({ options, value, onChange, multi = true }) {
return (
{options.map(opt => {
const v = typeof opt === "string" ? opt : opt.v;
const l = typeof opt === "string" ? opt : opt.l;
const active = multi ? value.includes(v) : value === v;
return (
);
})}
);
}
function Segmented({ options, value, onChange }) {
return (
{options.map(opt => (
))}
);
}
function FsToggle({ label, sub, checked, onChange }) {
return (
);
}
function DualRange({ min, max, step = 1, value, onChange, fmt }) {
const [low, high] = value;
const span = max - min || 1;
const lowPct = ((low - min) / span) * 100;
const highPct = ((high - min) / span) * 100;
return (
);
}
/* ──────────────────────────────────────────────────────── */
/* FILTER SIDEBAR */
/* ──────────────────────────────────────────────────────── */
function FilterSidebar({ filters, setFilters, count, onClear, onSave, scope, location, onLocationConsent }) {
const set = (k, v) => setFilters(f => ({ ...f, [k]: v }));
const beatLike = filters.types.length === 0 || filters.types.some(t => t === "Beat custom" || t === "Custom production");
return (
);
}
/* ──────────────────────────────────────────────────────── */
/* ACTIVE FILTERS CHIPS */
/* ──────────────────────────────────────────────────────── */
function ActiveChips({ filters, setFilters }) {
const chips = [];
const set = (k, v) => setFilters(f => ({ ...f, [k]: v }));
filters.types.forEach(t => chips.push({ k: `t-${t}`, label: t, remove: () => set("types", filters.types.filter(x => x !== t)) }));
filters.genres.forEach(g => chips.push({ k: `g-${g}`, label: g, remove: () => set("genres", filters.genres.filter(x => x !== g)) }));
if (filters.budget[0] > 0 || filters.budget[1] < 2000) {
chips.push({ k: "budget", label: `€${filters.budget[0]}–€${filters.budget[1]}${filters.budget[1] === 2000 ? "+" : ""}`, remove: () => set("budget", [0, 2000]) });
}
if (filters.geo !== "italy") {
const map = { europe: "Europa", world: "Mondo", near: `Vicino a me · ${filters.radius}km` };
chips.push({ k: "geo", label: map[filters.geo] || filters.geo, remove: () => { set("geo", "italy"); } });
}
if (filters.offers !== "all") {
const m = { lt3: "< 3 offerte", mid: "3-10 offerte", high: "10+ offerte" };
chips.push({ k: "offers", label: m[filters.offers], remove: () => set("offers", "all") });
}
if (filters.deadline !== "all") {
const m = { rush: "Rush <7gg", week2: "1-2 sett.", week4: "2-4 sett.", longer: ">4 sett." };
chips.push({ k: "deadline", label: m[filters.deadline], remove: () => set("deadline", "all") });
}
if (filters.verifiedOnly) chips.push({ k: "v", label: "Verificati", remove: () => set("verifiedOnly", false) });
if (filters.briefCompleteOnly) chips.push({ k: "b", label: "Brief completo", remove: () => set("briefCompleteOnly", false) });
if (filters.noOffersOnly) chips.push({ k: "n", label: "0 offerte", remove: () => set("noOffersOnly", false) });
if (filters.bpmRange[0] > 60 || filters.bpmRange[1] < 200) {
chips.push({ k: "bpm", label: `${filters.bpmRange[0]}–${filters.bpmRange[1]} BPM`, remove: () => set("bpmRange", [60, 200]) });
}
filters.keys.forEach(k => chips.push({ k: `k-${k}`, label: k, remove: () => set("keys", filters.keys.filter(x => x !== k)) }));
if (chips.length === 0) return null;
return (
{chips.map(c => (
))}
);
}
/* ──────────────────────────────────────────────────────── */
/* FILTERING LOGIC */
/* ──────────────────────────────────────────────────────── */
function applyFilters(reqs, filters, scope) {
return reqs.filter(r => {
if (scope === "forYou" && r.outOfMatch) return false;
if (filters.search) {
const q = filters.search.toLowerCase();
if (!(r.title + " " + r.brief).toLowerCase().includes(q)) return false;
}
if (filters.types.length > 0 && !filters.types.includes(r.kind)) return false;
if (filters.genres.length > 0 && !r.genres.some(g => filters.genres.includes(g))) return false;
if (r.budget.max < filters.budget[0]) return false;
if (filters.budget[1] < 2000 && r.budget.min > filters.budget[1]) return false;
// Geo
if (filters.geo === "near" && r.city !== "Milano") return false;
if (filters.geo === "italy" && r.country !== "IT") return false;
if (filters.geo === "europe" && !["IT", "DE", "FR", "ES", "UK", "NL"].includes(r.country)) return false;
// Offers
if (filters.offers === "lt3" && r.offers >= 3) return false;
if (filters.offers === "mid" && (r.offers < 3 || r.offers > 10)) return false;
if (filters.offers === "high" && r.offers <= 10) return false;
if (filters.noOffersOnly && r.offers > 0) return false;
// Deadline
if (filters.deadline === "rush" && r.deadlineDays >= 7) return false;
if (filters.deadline === "week2" && (r.deadlineDays < 7 || r.deadlineDays > 14)) return false;
if (filters.deadline === "week4" && (r.deadlineDays < 14 || r.deadlineDays > 30)) return false;
if (filters.deadline === "longer" && r.deadlineDays <= 30) return false;
if (filters.verifiedOnly && !r.verified) return false;
if (filters.briefCompleteOnly && !r.briefComplete) return false;
// BPM (only beat-like)
if (r.bpm != null && (r.bpm < filters.bpmRange[0] || r.bpm > filters.bpmRange[1])) return false;
return true;
});
}
function sortReqs(items, sort) {
const copy = [...items];
if (sort === "match") copy.sort((a, b) => (b.genres.some(g => NICO_GENRES.includes(g)) ? 1 : 0) - (a.genres.some(g => NICO_GENRES.includes(g)) ? 1 : 0));
if (sort === "recent") copy.sort((a, b) => a.postedHours - b.postedHours);
if (sort === "budget") copy.sort((a, b) => b.budget.max - a.budget.max);
if (sort === "offers") copy.sort((a, b) => a.offers - b.offers);
if (sort === "deadline") copy.sort((a, b) => b.deadlineDays - a.deadlineDays);
return copy;
}
/* ──────────────────────────────────────────────────────── */
/* CARD + DETAIL */
/* ──────────────────────────────────────────────────────── */
function ReqCard({ r, active, onClick }) {
const isHot = r.postedHours <= 6 && r.offers <= 4;
return (
);
}
function ReqDetail({ r }) {
if (!r) return (
Seleziona una richiesta
Apri una richiesta dal feed per leggere il brief e inviare un'offerta.
);
return (
{r.kind}
{r.new && NEW}
Postata {r.posted} · scade tra {r.expiresIn}
{r.title}
Budget {r.budget.fixed ? `€${r.budget.min}` : `€${r.budget.min}–€${r.budget.max}`}
·
Deadline {r.deadlineDays}gg
·
{r.offers} offerte ricevute
·
scade tra {r.expiresIn}
{r.attachments && (
Allegati dell'artista {r.attachments.length}
{r.attachments.map((a, i) => (
))}
)}
);
}
function SendOffer({ r }) {
const [price, setPrice] = React.useState(r.budget.max);
const [days, setDays] = React.useState(r.deadlineDays);
const [msg, setMsg] = React.useState("");
const [sent, setSent] = React.useState(false);
React.useEffect(() => { setPrice(r.budget.max); setDays(r.deadlineDays); setSent(false); setMsg(""); }, [r.id]);
if (sent) {
return (
Offerta inviata!
Hai 14 offerte attive su 15 max. Ti notificheremo quando {r.artist} risponde.
Vedi offerte inviate
);
}
return (
Invia un'offerta
Opzionale ma alza le chance di accettazione
Contratto standard Sonara · escrow attivo
);
}
/* ──────────────────────────────────────────────────────── */
/* SAVE SEARCH MODAL */
/* ──────────────────────────────────────────────────────── */
function SaveSearchModal({ open, onClose }) {
const [name, setName] = React.useState("");
const [alert, setAlert] = React.useState("instant");
if (!open) return null;
return (
e.stopPropagation()}>
Salva questa ricerca
Ricevi notifiche quando arrivano nuove richieste che matchano i tuoi filtri.
setName(e.target.value)}/>
{[
{ v: "instant", l: "Immediata", s: "Push + email" },
{ v: "daily", l: "1 volta al giorno", s: "Digest 8:00" },
{ v: "weekly", l: "1 volta a settimana", s: "Lun. 9:00" },
{ v: "none", l: "Solo salvata", s: "Niente notifica" },
].map(o => (
))}
);
}
/* ──────────────────────────────────────────────────────── */
/* SORT MENU (custom dropdown) */
/* ──────────────────────────────────────────────────────── */
const SORT_OPTIONS = [
{ v: "match", l: "Match score" },
{ v: "recent", l: "Più recenti" },
{ v: "budget", l: "Budget alto" },
{ v: "offers", l: "Meno offerte" },
{ v: "deadline", l: "Deadline lontana" },
];
function SortMenu({ sort, setSort }) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
React.useEffect(() => {
const onDown = (e) => { if (open && ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const current = SORT_OPTIONS.find(o => o.v === sort) || SORT_OPTIONS[0];
return (
{open && (
{SORT_OPTIONS.map(o => (
))}
)}
);
}
/* ──────────────────────────────────────────────────────── */
/* PAGE */
/* ──────────────────────────────────────────────────────── */
function OpportunitaPage() {
const [selected, setSelected] = React.useState("REQ-9214");
const [scope, setScope] = React.useState("forYou");
const [filters, setFilters] = React.useState(DEFAULT_FILTERS);
const [sort, setSort] = React.useState("match");
const [saveOpen, setSaveOpen] = React.useState(false);
const [filtersMobileOpen, setFiltersMobileOpen] = React.useState(false);
const [railOpen, setRailOpen] = React.useState(() => {
try { return localStorage.getItem("sonara-opp-rail-open") !== "0"; } catch (e) { return true; }
});
React.useEffect(() => {
try { localStorage.setItem("sonara-opp-rail-open", railOpen ? "1" : "0"); } catch (e) {}
}, [railOpen]);
const [location, setLocation] = React.useState({ status: "idle", city: null, country: null });
const requestLocation = () => {
setLocation({ status: "requesting", city: null, country: null });
setTimeout(() => {
setLocation({ status: "granted", city: "Milano", country: "IT" });
setFilters(f => ({ ...f, geo: "near" }));
}, 1400);
};
const filtered = applyFilters(REQS, filters, scope);
const sorted = sortReqs(filtered, sort);
const current = REQS.find(r => r.id === selected);
const forYouCount = applyFilters(REQS, filters, "forYou").length;
const allCount = applyFilters(REQS, filters, "all").length;
const onClear = () => setFilters(DEFAULT_FILTERS);
return (
Opportunità
2 offerte attive · 13 slot disponibili
·
10.247 richieste open sulla piattaforma
setSaveOpen(true)}
scope={scope}
location={location}
onLocationConsent={requestLocation}
/>
{sorted.map(r => (
setSelected(r.id)}/>
))}
{sorted.length === 0 && (
Nessuna richiesta corrisponde
Allenta uno dei filtri o salva la ricerca per essere avvisato quando arriva.
)}
{filtersMobileOpen && (
setFiltersMobileOpen(false)}/>
)}
setSaveOpen(false)}/>
);
}
window.OpportunitaPage = OpportunitaPage;