/* global supabase */ // ========================================================== // Clippr API layer. // Wraps Supabase calls so components don't import supabase directly. // All functions return { data, error } and never throw. // ========================================================== (function(){ const cfg = window.CLIPPR_CONFIG || {}; const configured = cfg.SUPABASE_URL && cfg.SUPABASE_ANON_KEY; let client = null; if (configured && window.supabase && window.supabase.createClient) { client = window.supabase.createClient(cfg.SUPABASE_URL, cfg.SUPABASE_ANON_KEY, { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: false } }); } const ok = (data) => ({ data, error: null }); const err = (error) => ({ data: null, error }); const requireClient = () => { if (!client) return err({ message: "Backend not configured. Set SUPABASE_URL + SUPABASE_ANON_KEY in src/config.js." }); return null; }; // ---------- Auth ---------- async function signUp({ email, password, displayName }){ const r = requireClient(); if (r) return r; const { data, error } = await client.auth.signUp({ email, password, options: { data: { display_name: displayName || email.split("@")[0] } } }); return error ? err(error) : ok(data); } async function signIn({ email, password }){ const r = requireClient(); if (r) return r; const { data, error } = await client.auth.signInWithPassword({ email, password }); return error ? err(error) : ok(data); } async function signOut(){ const r = requireClient(); if (r) return r; const { error } = await client.auth.signOut(); return error ? err(error) : ok(true); } async function getSession(){ if (!client) return ok(null); const { data, error } = await client.auth.getSession(); return error ? err(error) : ok(data.session); } function onAuthStateChange(cb){ if (!client) return () => {}; const { data: sub } = client.auth.onAuthStateChange((_event, session) => cb(session)); return () => sub.subscription.unsubscribe(); } // ---------- Profile ---------- async function getMyProfile(){ const r = requireClient(); if (r) return r; const { data: { user } } = await client.auth.getUser(); if (!user) return ok(null); const { data, error } = await client.from("profiles").select("*").eq("id", user.id).maybeSingle(); return error ? err(error) : ok(data); } async function updateMyProfile(patch){ const r = requireClient(); if (r) return r; const { data: { user } } = await client.auth.getUser(); if (!user) return err({ message: "Not signed in" }); const { data, error } = await client.from("profiles").update(patch).eq("id", user.id).select().maybeSingle(); return error ? err(error) : ok(data); } // ---------- Campaigns ---------- async function listLiveCampaigns(){ const r = requireClient(); if (r) return r; const { data, error } = await client.from("campaigns").select("*").eq("status", "live").order("created_at", { ascending: false }); return error ? err(error) : ok(data || []); } async function listAllCampaigns(){ const r = requireClient(); if (r) return r; const { data, error } = await client.from("campaigns").select("*").order("created_at", { ascending: false }); return error ? err(error) : ok(data || []); } async function getCampaignBySlug(slug){ const r = requireClient(); if (r) return r; const { data, error } = await client.from("campaigns").select("*").eq("slug", slug).maybeSingle(); return error ? err(error) : ok(data); } async function getCampaign(id){ const r = requireClient(); if (r) return r; const { data, error } = await client.from("campaigns").select("*").eq("id", id).maybeSingle(); return error ? err(error) : ok(data); } async function upsertCampaign(c){ const r = requireClient(); if (r) return r; const payload = { ...c, updated_at: new Date().toISOString() }; const { data, error } = await client.from("campaigns").upsert(payload).select().maybeSingle(); return error ? err(error) : ok(data); } // ---------- Helper: attach profile rows to a list of records ---------- // Avoids relying on implicit FK joins (clips/payouts.user_id references // auth.users, not profiles, so PostgREST can't embed profiles directly). async function attachProfiles(rows, profileFields = "id, display_name, handle, paypal_email, country"){ if (!rows || rows.length === 0) return rows || []; const userIds = [...new Set(rows.map(r => r.user_id).filter(Boolean))]; if (userIds.length === 0) return rows; const { data, error } = await client.from("profiles").select(profileFields).in("id", userIds); if (error) return rows; const map = {}; for (const p of (data || [])) map[p.id] = p; return rows.map(r => ({ ...r, profiles: map[r.user_id] || null })); } // ---------- Clips ---------- async function submitClip({ campaignId, url, notes, platform }){ const r = requireClient(); if (r) return r; const { data: { user } } = await client.auth.getUser(); if (!user) return err({ message: "Not signed in" }); const detected = platform || detectPlatform(url); const { data, error } = await client.from("clips").insert({ user_id: user.id, campaign_id: campaignId, url, notes: notes || null, platform: detected, status: "pending", }).select().maybeSingle(); return error ? err(error) : ok(data); } async function listMyClips(){ const r = requireClient(); if (r) return r; const { data: { user } } = await client.auth.getUser(); if (!user) return ok([]); const { data, error } = await client.from("clips").select("*, campaigns(name, slug, rpm, min_views, tint)").eq("user_id", user.id).order("submitted_at", { ascending: false }); return error ? err(error) : ok(data || []); } async function listPendingClips(){ const r = requireClient(); if (r) return r; const { data, error } = await client.from("clips").select("*, campaigns(name, slug, rpm, min_views, tint)").eq("status", "pending").order("submitted_at", { ascending: false }); if (error) return err(error); const withProfiles = await attachProfiles(data || []); return ok(withProfiles); } async function listAllClips(){ const r = requireClient(); if (r) return r; const { data, error } = await client.from("clips").select("*, campaigns(name, slug, rpm, min_views, tint)").order("submitted_at", { ascending: false }).limit(200); if (error) return err(error); const withProfiles = await attachProfiles(data || []); return ok(withProfiles); } async function reviewClip(id, { status, views, rejection_reason }){ const r = requireClient(); if (r) return r; const { data: { user } } = await client.auth.getUser(); const patch = { status, views: typeof views === "number" ? views : undefined, rejection_reason: status === "rejected" ? (rejection_reason || null) : null, reviewed_at: new Date().toISOString(), reviewed_by: user ? user.id : null, }; Object.keys(patch).forEach(k => patch[k] === undefined && delete patch[k]); const { data, error } = await client.from("clips").update(patch).eq("id", id).select().maybeSingle(); if (error) return err(error); // Recompute earnings await client.rpc("recompute_clip_earned", { clip_id: id }); const { data: refreshed } = await client.from("clips").select("*").eq("id", id).maybeSingle(); return ok(refreshed || data); } async function updateClipViews(id, views){ return reviewClip(id, { status: undefined, views }); } // ---------- Balance ---------- async function getMyBalance(){ const r = requireClient(); if (r) return r; const { data: { user } } = await client.auth.getUser(); if (!user) return ok({ total_earned: 0, total_pending_paid: 0, available_balance: 0 }); const { data, error } = await client.from("clipper_balances").select("*").eq("user_id", user.id).maybeSingle(); return error ? err(error) : ok(data || { total_earned: 0, total_pending_paid: 0, available_balance: 0 }); } // ---------- Payouts ---------- async function requestPayout({ amount, method, destination }){ const r = requireClient(); if (r) return r; const { data: { user } } = await client.auth.getUser(); if (!user) return err({ message: "Not signed in" }); const { data, error } = await client.from("payouts").insert({ user_id: user.id, amount, method, destination, status: "pending" }).select().maybeSingle(); return error ? err(error) : ok(data); } async function listMyPayouts(){ const r = requireClient(); if (r) return r; const { data: { user } } = await client.auth.getUser(); if (!user) return ok([]); const { data, error } = await client.from("payouts").select("*").eq("user_id", user.id).order("requested_at", { ascending: false }); return error ? err(error) : ok(data || []); } async function listAllPayouts(){ const r = requireClient(); if (r) return r; const { data, error } = await client.from("payouts").select("*").order("requested_at", { ascending: false }).limit(200); if (error) return err(error); return ok(await attachProfiles(data || [])); } async function listPendingPayouts(){ const r = requireClient(); if (r) return r; const { data, error } = await client.from("payouts").select("*").in("status", ["pending","processing"]).order("requested_at", { ascending: true }); if (error) return err(error); return ok(await attachProfiles(data || [])); } async function listRecentPaidPayouts(limit = 12){ const r = requireClient(); if (r) return r; const { data, error } = await client.from("payouts").select("amount, paid_at, user_id").eq("status", "paid").order("paid_at", { ascending: false }).limit(limit); if (error) return err(error); return ok(await attachProfiles(data || [], "id, display_name, handle, country")); } async function processPayout(id, { status, txn_ref, notes }){ const r = requireClient(); if (r) return r; const { data: { user } } = await client.auth.getUser(); const patch = { status, txn_ref: txn_ref || null, notes: notes || null, }; if (status === "paid") { patch.paid_at = new Date().toISOString(); patch.paid_by = user ? user.id : null; } const { data, error } = await client.from("payouts").update(patch).eq("id", id).select().maybeSingle(); return error ? err(error) : ok(data); } // ---------- Admin stats ---------- async function getAdminStats(){ const r = requireClient(); if (r) return r; const [pendingClips, allClips, pendingPayouts, allPayouts, profiles] = await Promise.all([ client.from("clips").select("id", { count: "exact", head: true }).eq("status", "pending"), client.from("clips").select("views, earned, status"), client.from("payouts").select("amount", { count: "exact" }).in("status", ["pending","processing"]), client.from("payouts").select("amount, status"), client.from("profiles").select("id", { count: "exact", head: true }), ]); const totalViews = (allClips.data || []).filter(c => c.status === "approved").reduce((s,c) => s + (c.views || 0), 0); const totalPaid = (allPayouts.data || []).filter(p => p.status === "paid").reduce((s,p) => s + Number(p.amount || 0), 0); const pendingPayoutAmount = (pendingPayouts.data || []).reduce((s,p) => s + Number(p.amount || 0), 0); return ok({ pendingClipsCount: pendingClips.count || 0, pendingPayoutsCount: (pendingPayouts.data || []).length, pendingPayoutAmount, totalViews, totalPaid, totalClippers: profiles.count || 0, }); } // ---------- Helpers ---------- function detectPlatform(url){ if (!url) return "other"; const u = url.toLowerCase(); if (u.includes("tiktok.com")) return "tiktok"; if (u.includes("instagram.com")) return "instagram"; if (u.includes("youtube.com") || u.includes("youtu.be")) return "youtube"; return "other"; } // Extract YouTube video id from any youtube/shorts/youtu.be URL. function youtubeId(url){ if (!url) return null; try { const u = new URL(url); const host = u.hostname.replace(/^www\./, "").toLowerCase(); if (host === "youtu.be") return u.pathname.slice(1).split("/")[0] || null; if (host.endsWith("youtube.com") || host.endsWith("youtube-nocookie.com")) { if (u.pathname.startsWith("/shorts/")) return u.pathname.split("/")[2] || null; if (u.pathname.startsWith("/embed/")) return u.pathname.split("/")[2] || null; if (u.pathname === "/watch") return u.searchParams.get("v"); } return null; } catch { return null; } } function isYoutubeShorts(url){ return /youtube\.com\/shorts\//i.test(url||""); } function youtubeThumb(id){ return id ? `https://img.youtube.com/vi/${id}/hqdefault.jpg` : null; } function youtubeEmbed(id, { autoplay=true, mute=false } = {}){ if (!id) return null; const params = new URLSearchParams({ autoplay: autoplay?"1":"0", mute: mute?"1":"0", playsinline:"1", rel:"0", modestbranding:"1" }); return `https://www.youtube-nocookie.com/embed/${id}?${params.toString()}`; } // ---------- Campaign stats (admin-only / informational) ---------- async function getCampaignStats(campaignId){ const r = requireClient(); if (r) return r; const { data, error } = await client.from("clips").select("user_id, status, views, earned").eq("campaign_id", campaignId); if (error) return err(error); const rows = data || []; const clipperSet = new Set(rows.map(r => r.user_id)); const totalClips = rows.length; const totalViews = rows.filter(r => r.status === "approved").reduce((s,r) => s + (r.views || 0), 0); const totalEarned = rows.filter(r => r.status === "approved").reduce((s,r) => s + Number(r.earned || 0), 0); return ok({ clipperCount: clipperSet.size, totalClips, totalViews, totalEarned }); } // Expose window.api = { isConfigured: !!client, client, // auth signUp, signIn, signOut, getSession, onAuthStateChange, // profile getMyProfile, updateMyProfile, // campaigns listLiveCampaigns, listAllCampaigns, getCampaignBySlug, getCampaign, upsertCampaign, getCampaignStats, // clips submitClip, listMyClips, listPendingClips, listAllClips, reviewClip, updateClipViews, // balance getMyBalance, // payouts requestPayout, listMyPayouts, listAllPayouts, listPendingPayouts, listRecentPaidPayouts, processPayout, // admin getAdminStats, // helpers detectPlatform, youtubeId, isYoutubeShorts, youtubeThumb, youtubeEmbed, }; })();