import { useState, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { Music, Upload, CheckCircle2, AlertCircle, Terminal, Cpu, Plus, Trash2, UserPlus, FileAudio, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Card, CardContent } from "@/components/ui/card"; import { useWallet } from "@/hooks/useWallet"; const ISRC_PATTERN = /^[A-Z]{2}-[A-Z0-9]{3}-\d{2}-\d{5}$/; const EVM_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/; const IPI_PATTERN = /^\d{9,11}$/; const VALID_ROLES = ["Songwriter", "Composer", "Lyricist", "Publisher", "Admin Publisher"] as const; type ContributorRole = typeof VALID_ROLES[number]; interface Contributor { id: string; address: string; ipiNumber: string; role: ContributorRole; bps: string; } interface UploadResult { cid: string; isrc: string; band: number; rarity: string; dsp_ready: boolean; registration_id?: string; soulbound_pending?: boolean; ddex_submitted?: boolean; } const makeId = () => Math.random().toString(36).slice(2, 9); const emptyContributor = (): Contributor => ({ id: makeId(), address: "", ipiNumber: "", role: "Songwriter", bps: "", }); const MetadataUpload = () => { const { wallet, authHeaders } = useWallet(); const audioInputRef = useRef(null); const [step, setStep] = useState<"form" | "uploading" | "registering" | "success">("form"); const [serverError, setServerError] = useState(null); const [result, setResult] = useState(null); const [validationErrors, setValidationErrors] = useState>({}); const [audioFile, setAudioFile] = useState(null); const [title, setTitle] = useState(""); const [isrc, setIsrc] = useState(""); const [contributors, setContributors] = useState([ { ...emptyContributor(), address: wallet.address, role: "Songwriter" }, ]); const bpsSum = contributors.reduce((sum, c) => sum + (parseInt(c.bps) || 0), 0); const bpsValid = bpsSum === 10000; const addContributor = () => { if (contributors.length < 16) { setContributors((prev) => [...prev, emptyContributor()]); } }; const removeContributor = (id: string) => { setContributors((prev) => prev.filter((c) => c.id !== id)); }; const updateContributor = (id: string, field: keyof Contributor, value: string) => { setContributors((prev) => prev.map((c) => (c.id === id ? { ...c, [field]: value } : c)) ); const key = `${id}_${field}`; if (validationErrors[key]) { setValidationErrors((prev) => ({ ...prev, [key]: "" })); } }; const validate = (): boolean => { const errors: Record = {}; if (!title.trim()) errors.title = "Song title is required."; const isrcNorm = isrc.trim().toUpperCase(); if (!isrcNorm) { errors.isrc = "ISRC code is required."; } else if (!ISRC_PATTERN.test(isrcNorm)) { errors.isrc = "Format: CC-XXX-YY-NNNNN e.g. US-ABC-24-00001"; } if (!audioFile) errors.audio = "Audio file is required."; if (contributors.length === 0) { errors.contributors = "At least one contributor is required."; } contributors.forEach((c) => { if (!EVM_ADDRESS_PATTERN.test(c.address)) { errors[`${c.id}_address`] = "Must be a valid 0x EVM wallet address (42 chars)."; } if (!IPI_PATTERN.test(c.ipiNumber.replace(/\D/g, ""))) { errors[`${c.id}_ipiNumber`] = "IPI must be 9–11 digits."; } const bpsVal = parseInt(c.bps); if (isNaN(bpsVal) || bpsVal <= 0 || bpsVal > 10000) { errors[`${c.id}_bps`] = "Must be 1–10000."; } }); if (!errors.contributors && !bpsValid) { errors.bpsSum = `Splits must total 10,000 bps (100%). Current: ${bpsSum.toLocaleString()}`; } setValidationErrors(errors); return Object.keys(errors).length === 0; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!wallet.connected || !validate()) return; setServerError(null); try { // ── Step 1: Upload audio to BTFS via /api/upload (multipart) ──────── setStep("uploading"); const fd = new FormData(); fd.append("title", title.trim()); fd.append("artist", wallet.address); fd.append("isrc", isrc.trim().toUpperCase()); fd.append("audio", audioFile!); const uploadRes = await fetch("/api/upload", { method: "POST", headers: { ...authHeaders() }, body: fd, }); if (!uploadRes.ok) { const text = await uploadRes.text().catch(() => ""); throw new Error(`Audio upload failed (${uploadRes.status}): ${text || uploadRes.statusText}`); } const uploadData = await uploadRes.json(); const { cid, band, rarity, dsp_ready } = uploadData; // ── Step 2: Register publishing agreement via /api/register (JSON) ── setStep("registering"); const registerPayload = { title: title.trim(), isrc: isrc.trim().toUpperCase(), btfs_cid: cid, band: band ?? 0, contributors: contributors.map((c) => ({ address: c.address.trim().toLowerCase(), ipi_number: c.ipiNumber.replace(/\D/g, ""), role: c.role, bps: parseInt(c.bps), })), }; const regRes = await fetch("/api/register", { method: "POST", headers: { "Content-Type": "application/json", ...authHeaders(), }, body: JSON.stringify(registerPayload), }); if (!regRes.ok) { const text = await regRes.text().catch(() => ""); throw new Error(`Registration failed (${regRes.status}): ${text || regRes.statusText}`); } const regData = await regRes.json(); setResult({ cid, isrc: isrc.trim().toUpperCase(), band: band ?? 0, rarity: rarity ?? "Common", dsp_ready: dsp_ready ?? false, registration_id: regData.registration_id, soulbound_pending: regData.soulbound_pending ?? true, ddex_submitted: regData.ddex_submitted ?? false, }); setStep("success"); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Submission failed. Please try again."; setServerError(message); setStep("form"); } }; const reset = () => { setStep("form"); setResult(null); setServerError(null); setAudioFile(null); setTitle(""); setIsrc(""); setContributors([{ ...emptyContributor(), address: wallet.address, role: "Songwriter" }]); setValidationErrors({}); if (audioInputRef.current) audioInputRef.current.value = ""; }; if (!wallet.connected) { return (

Access Denied

> Error: Wallet_Not_Connected
> Action: Connect a valid TronLink or Coinbase wallet to access the upload portal.

); } if (step === "success" && result) { return (

Transmission Successful

> ISRC: {result.isrc}
> BTFS CID: {result.cid}
> Band: {result.band} ({result.rarity})
{result.registration_id && (
> Reg ID: {result.registration_id}
)}
> DSP Ready: {result.dsp_ready ? "YES" : "PENDING"}
> DDEX Submitted: {result.ddex_submitted ? "YES" : "PENDING"}
{result.soulbound_pending && (
> Soulbound NFT: PENDING — all contributors must sign
  the on-chain publishing agreement from their wallets.
)}
); } const isProcessing = step === "uploading" || step === "registering"; return (

Upload Protocol

BTFS_Upload → Publishing_Agreement → Soulbound_NFT → DDEX

PROTOCOL: Audio is uploaded to BTFS. A publishing agreement is created on-chain linking all contributors via their IPI-verified wallets. Once all parties sign, a soulbound NFT is minted and the track is delivered to Spotify, Apple Music, and other DSPs via DDEX ERN 4.1.

{/* ── Core metadata ── */}
_track_metadata
{ setTitle(e.target.value); if (validationErrors.title) setValidationErrors(p => ({ ...p, title: "" })); }} disabled={isProcessing} /> {validationErrors.title &&

{validationErrors.title}

}
{ setIsrc(e.target.value); if (validationErrors.isrc) setValidationErrors(p => ({ ...p, isrc: "" })); }} disabled={isProcessing} />

Format: CC-XXX-YY-NNNNN

{validationErrors.isrc &&

{validationErrors.isrc}

}
audioInputRef.current?.click()} > { const f = e.target.files?.[0] ?? null; setAudioFile(f); if (validationErrors.audio) setValidationErrors(p => ({ ...p, audio: "" })); }} disabled={isProcessing} /> {audioFile ? (
{audioFile.name} ({(audioFile.size / 1024 / 1024).toFixed(2)} MB)
) : (
Click to select audio file (max 100 MB)
)}
{validationErrors.audio &&

{validationErrors.audio}

}
{/* ── Contributors ── */}
_contributors_&_publishing_splits
0 ? "text-yellow-500" : "text-zinc-600"}`}> {bpsSum.toLocaleString()} / 10,000 bps

Add all songwriters, composers, and publishers. Splits must total exactly{" "} 10,000 basis points (100%). Each contributor must have a KYC-verified wallet linked to their IPI number before the soulbound NFT will mint.

{contributors.map((c, idx) => (
Party_{String(idx + 1).padStart(2, "0")} {contributors.length > 1 && ( )}
updateContributor(c.id, "address", e.target.value)} placeholder="0x..." className="bg-black border-zinc-800 rounded-none focus:border-primary text-[11px] font-mono h-8" disabled={isProcessing} /> {validationErrors[`${c.id}_address`] && (

{validationErrors[`${c.id}_address`]}

)}
updateContributor(c.id, "ipiNumber", e.target.value.replace(/\D/g, "").slice(0, 11))} placeholder="00523879412" className="bg-black border-zinc-800 rounded-none focus:border-primary text-[11px] font-mono h-8" disabled={isProcessing} maxLength={11} /> {validationErrors[`${c.id}_ipiNumber`] && (

{validationErrors[`${c.id}_ipiNumber`]}

)}
updateContributor(c.id, "bps", e.target.value.replace(/\D/g, "").slice(0, 5))} placeholder="e.g. 5000 = 50%" className="bg-black border-zinc-800 rounded-none focus:border-primary text-[11px] font-mono h-8" disabled={isProcessing} /> {validationErrors[`${c.id}_bps`] && (

{validationErrors[`${c.id}_bps`]}

)}
))}
{validationErrors.bpsSum && (

{validationErrors.bpsSum}

)} {validationErrors.contributors && (

{validationErrors.contributors}

)} {contributors.length < 16 && ( )}
{/* ── Error / status ── */} {serverError && (
> Error: {serverError}
)} {/* ── Submit ── */}
Signer_ID {wallet.address}
{isProcessing && (
{step === "uploading" && "> Step 1/2: Uploading audio to BTFS..."} {step === "registering" && "> Step 2/2: Registering publishing agreement + DDEX delivery..."}
)} {!bpsValid && contributors.length > 0 && bpsSum > 0 && (

Adjust splits so they total exactly 10,000 bps to enable submission.

)}
); }; export default MetadataUpload;