// ───────────────────────────────────────────────────────────────────────────── // SalesLab AI — v3.6 (Phase 1.5) // Updated: 2026-03-14 // Changes: // - NEW: Sales IQ Assessment — daily 5-question AI-powered sales scenario test // - Sales IQ: 3-step role selector (9 industries × 6+ roles each, including Kitchen & Bath) // - Sales IQ: Claude generates role-specific scenarios tailored to industry + role // - Sales IQ: Scores 5 skill categories: Discovery, Objection Handling, Closing, Prospecting, Negotiation // - Sales IQ: Free users see score + tier + leaderboard rank; Pro users get full skill breakdown // - Sales IQ: Viral loop — pre-written LinkedIn post, challenge link, share nudge on dashboard // - Sales IQ: 7-day streak tracker, seeded leaderboard, daily reset // - Sales IQ: Post-test share card with all skill scores and coaching pills // - Sales IQ: Plugged into dashboard home below leaderboard teaser (Pro users only shown full card) // - Sales IQ: showSalesIQ state added to main app alongside showGame, showLeaderboard, etc. // - Dashboard: Sales IQ card added to home screen with "Take today's test →" CTA // - Sales IQ sidebar entry added to PRO_SIDEBAR // ───────────────────────────────────────────────────────────────────────────── const { useState, useRef, useEffect } = React; // ─── Score Color System ─────────────────────────────────────────────────────── // 1–59 red · 60–74 orange · 75–91 yellow · 92–100 green function scoreColor(score) { if (score >= 92) return { text: "#16a34a", bar: "#22c55e", bg: "#f0fdf4", border: "#bbf7d0", label: "Elite" }; if (score >= 75) return { text: "#b45309", bar: "#f59e0b", bg: "#fffbeb", border: "#fde68a", label: "Strong" }; if (score >= 60) return { text: "#c2410c", bar: "#f97316", bg: "#fff7ed", border: "#fed7aa", label: "Developing" }; return { text: "#dc2626", bar: "#ef4444", bg: "#fef2f2", border: "#fecaca", label: "Needs Work" }; } // ─── Shuffle Array (Fisher-Yates) ───────────────────────────────────────────── function shuffleArray(arr) { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } // MUST be defined first — other constants reference BRAND_CONFIG.appUrl below. // To white label for a client: // 1. Duplicate this file // 2. Update the values below with the client's brand // 3. Set their own SUPABASE_URL + SUPABASE_ANON_KEY (new Supabase project) // 4. Set their Stripe links or remove billing and invoice them directly // 5. Deploy to their subdomain e.g. app.clientname.com const BRAND_CONFIG = { appName: "SalesLab AI", appNameShort: "SalesLab AI", tagline: "TRAIN · TEST · CLOSE", logoPath: "/SalesLab_AI_icon.png", appUrl: "https://thesaleslabai.com", supportEmail: "support@thesaleslabai.com", primaryColor: "#0f172a", primaryColor2: "#1e293b", }; // ─── Config ──────────────────────────────────────────────────────────────────── const SUPABASE_URL = "https://nahextkqnsmluuskjxps.supabase.co"; const SUPABASE_ANON_KEY = "sb_publishable_FhMyQlZK8F52DOfjCPlKmQ_ERuR4-cS"; const STRIPE_PRO_LINK = "https://www.paypal.com/webapps/billing/plans/subscribe?plan_id=P-5Y1401853D764390CNG4Z6GQ"; const STRIPE_TEAM_LINK = "https://www.paypal.com/webapps/billing/plans/subscribe?plan_id=P-2BB96861PG443164MNG4Z7SA"; const STRIPE_TOPUP_LINK = "https://buy.stripe.com/cNi3cueU35D4dnS8nZcIE06"; const APP_URL = BRAND_CONFIG.appUrl; // Payment link helper. // PayPal links get custom_id=userId so the webhook knows which user to activate. // Stripe topup links get client_reference_id=userId for the same reason. function stripeLink(baseLink, userId) { if (!baseLink.startsWith("https://")) return baseLink; if (baseLink.includes("paypal.com")) { if (!userId) return baseLink; try { const url = new URL(baseLink); url.searchParams.set("custom_id", userId); return url.toString(); } catch { return baseLink; } } if (!userId) return baseLink; try { const url = new URL(baseLink); url.searchParams.set("client_reference_id", userId); return url.toString(); } catch { return baseLink; } } // ─── Usage Limits ───────────────────────────────────────────────────────────── const FREE_MAX_PROMPTS_PER_DAY = 5; const PRO_MAX_TOKENS_PER_MONTH = 300000; const PRO_TOKEN_WARNING_THRESHOLD = 10000; const TOKEN_TOPUP_AMOUNT = 300000; // ─── Supabase Client ────────────────────────────────────────────────────────── const supabase = (() => { const headers = { "apikey": SUPABASE_ANON_KEY, "Authorization": `Bearer ${SUPABASE_ANON_KEY}`, "Content-Type": "application/json", }; const auth = { signInWithGoogle: async () => { const iqParam = new URLSearchParams(window.location.search).get("iq"); const redirectBase = `${APP_URL}/app`; const redirectTo = iqParam ? `${redirectBase}?iq=${iqParam}` : redirectBase; const url = `${SUPABASE_URL}/auth/v1/authorize?provider=google&redirect_to=${encodeURIComponent(redirectTo)}`; window.location.href = url; }, signInWithOtp: async (email) => { const iqParam = new URLSearchParams(window.location.search).get("iq"); const redirectTo = iqParam ? `${APP_URL}/app?iq=${iqParam}` : `${APP_URL}/app`; const res = await fetch(`${SUPABASE_URL}/auth/v1/otp`, { method: "POST", headers, body: JSON.stringify({ email, create_user: true, options: { emailRedirectTo: redirectTo } }), }); return res.ok; }, getSession: () => { try { const raw = localStorage.getItem(`sb-${SUPABASE_URL.split("//")[1].split(".")[0]}-auth-token`); if (!raw) return null; const parsed = JSON.parse(raw); if (parsed.expires_at && Date.now() / 1000 > parsed.expires_at) return null; return parsed; } catch { return null; } }, handleCallback: () => { const hash = window.location.hash; if (!hash.includes("access_token")) return null; const params = new URLSearchParams(hash.replace("#", "")); const session = { access_token: params.get("access_token"), refresh_token: params.get("refresh_token"), expires_at: Date.now() / 1000 + parseInt(params.get("expires_in") || "3600"), user: { id: params.get("user_id") || "" }, }; localStorage.setItem( `sb-${SUPABASE_URL.split("//")[1].split(".")[0]}-auth-token`, JSON.stringify(session) ); window.history.replaceState({}, document.title, window.location.pathname); return session; }, signOut: async () => { const session = auth.getSession(); if (session?.access_token) { await fetch(`${SUPABASE_URL}/auth/v1/logout`, { method: "POST", headers: { ...headers, "Authorization": `Bearer ${session.access_token}` }, }).catch(() => {}); } localStorage.removeItem(`sb-${SUPABASE_URL.split("//")[1].split(".")[0]}-auth-token`); }, getUser: async (accessToken) => { const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, { headers: { ...headers, "Authorization": `Bearer ${accessToken}` }, }); if (!res.ok) return null; return res.json(); }, }; const db = { getUserTier: async (userId, accessToken) => { try { // Step 1: find an active subscription for this user const subRes = await fetch( `${SUPABASE_URL}/rest/v1/subscriptions?user_id=eq.${userId}&status=eq.active&select=plan_id&limit=1`, { headers: { ...headers, "Authorization": `Bearer ${accessToken}` } } ); if (subRes.ok) { const subs = await subRes.json(); if (subs.length && subs[0].plan_id) { // Step 2: resolve plan_id → plan name from plans table const planRes = await fetch( `${SUPABASE_URL}/rest/v1/plans?id=eq.${subs[0].plan_id}&select=name&limit=1`, { headers: { ...headers, "Authorization": `Bearer ${accessToken}` } } ); if (planRes.ok) { const plans = await planRes.json(); const name = (plans[0]?.name || "").toLowerCase(); if (name === "pro" || name === "team") return name; } } } // Step 3: fallback — check profiles.plan_id directly const profileRes = await fetch( `${SUPABASE_URL}/rest/v1/profiles?id=eq.${userId}&select=plan_id,status&limit=1`, { headers: { ...headers, "Authorization": `Bearer ${accessToken}` } } ); if (profileRes.ok) { const profiles = await profileRes.json(); const profile = profiles[0]; if (profile?.plan_id && profile?.status === "active") { const planRes2 = await fetch( `${SUPABASE_URL}/rest/v1/plans?id=eq.${profile.plan_id}&select=name&limit=1`, { headers: { ...headers, "Authorization": `Bearer ${accessToken}` } } ); if (planRes2.ok) { const plans2 = await planRes2.json(); const name2 = (plans2[0]?.name || "").toLowerCase(); if (name2 === "pro" || name2 === "team") return name2; } } } } catch (e) { console.warn("[getUserTier] Error:", e); } return "free"; }, upsertUser: async (userId, email, accessToken) => { // Upsert into profiles (not users — that table doesn't exist) await fetch(`${SUPABASE_URL}/rest/v1/profiles`, { method: "POST", headers: { ...headers, "Authorization": `Bearer ${accessToken}`, "Prefer": "resolution=merge-duplicates", }, body: JSON.stringify({ id: userId, email }), }).catch(() => {}); }, }; return { auth, db }; })(); // ─── Supabase REST helper (replaces supabase.from() calls) ──────────────────── const sbDB = (() => { const BASE = SUPABASE_URL + "/rest/v1"; const getToken = () => { try { const key = Object.keys(localStorage).find(k => k.startsWith("sb-") && k.endsWith("-auth-token")); if (key) { const session = JSON.parse(localStorage.getItem(key)); if (session?.access_token) return session.access_token; } } catch (e) {} return SUPABASE_ANON_KEY; }; const h = () => ({ "apikey": SUPABASE_ANON_KEY, "Authorization": `Bearer ${getToken()}`, "Content-Type": "application/json", "Prefer": "return=representation", }); const buildUrl = (table, filters = {}, options = {}) => { let url = `${BASE}/${table}?`; const params = []; Object.entries(filters).forEach(([k, v]) => { if (typeof v === "object" && v !== null) { Object.entries(v).forEach(([op, val]) => params.push(`${k}=${op}.${encodeURIComponent(val)}`)); } else { params.push(`${k}=eq.${encodeURIComponent(v)}`); } }); if (options.select) params.push(`select=${options.select}`); if (options.order) params.push(`order=${options.order}`); if (options.limit) params.push(`limit=${options.limit}`); return url + params.join("&"); }; return { select: async (table, filters = {}, options = {}) => { const url = buildUrl(table, filters, options); const res = await fetch(url, { headers: h() }); if (!res.ok) return { data: null, error: await res.text() }; const data = await res.json(); if (options.single) return { data: data[0] || null, error: null }; return { data, error: null }; }, insert: async (table, body) => { const res = await fetch(`${BASE}/${table}`, { method: "POST", headers: h(), body: JSON.stringify(body), }); const data = await res.json().catch(() => null); return { data, error: res.ok ? null : data }; }, update: async (table, body, filters = {}) => { const url = buildUrl(table, filters); const res = await fetch(url, { method: "PATCH", headers: h(), body: JSON.stringify(body), }); return { error: res.ok ? null : await res.text() }; }, upsert: async (table, body, options = {}) => { const headers = { ...h(), "Prefer": `resolution=${options.onConflict ? "merge-duplicates" : "merge-duplicates"},return=representation` }; const res = await fetch(`${BASE}/${table}`, { method: "POST", headers, body: JSON.stringify(body), }); return { error: res.ok ? null : await res.text() }; }, delete: async (table, filters = {}) => { const url = buildUrl(table, filters); const res = await fetch(url, { method: "DELETE", headers: h() }); return { error: res.ok ? null : await res.text() }; }, rpc: async (fn, body = {}) => { const res = await fetch(`${BASE}/rpc/${fn}`, { method: "POST", headers: h(), body: JSON.stringify(body), }); const data = await res.json().catch(() => null); return { data, error: res.ok ? null : (data || "RPC request failed") }; }, }; })(); // ─── Usage Tracking Helpers ─────────────────────────────────────────────────── const usageDB = { // Get or initialize usage record for a user getUsage: async (userId) => { const { data } = await sbDB.select("user_usage", { user_id: userId }, { single: true }); return data; }, // Initialize usage record for new user initUsage: async (userId) => { const now = new Date().toISOString(); const dayStart = getDayStart(); const monthStart = getMonthStart(); await sbDB.upsert("user_usage", { user_id: userId, day_start: dayStart, prompts_today: 0, month_start: monthStart, tokens_used_this_month: 0, topup_tokens_remaining: 0, topup_expires_at: null, updated_at: now, }, { onConflict: "user_id" }); }, // Increment daily prompt count (free users) incrementPrompt: async (userId) => { const usage = await usageDB.getUsage(userId); if (!usage) { await usageDB.initUsage(userId); return; } const dayStart = getDayStart(); const count = usage.day_start === dayStart ? (usage.prompts_today || 0) + 1 : 1; await sbDB.update("user_usage", { day_start: dayStart, prompts_today: count, updated_at: new Date().toISOString(), }, { user_id: userId }); }, // Add token usage (pro/team users) addTokens: async (userId, tokensUsed) => { const usage = await usageDB.getUsage(userId); if (!usage) { await usageDB.initUsage(userId); return; } const monthStart = getMonthStart(); // Reset if new month const base = usage.month_start === monthStart ? (usage.tokens_used_this_month || 0) : 0; // Deduct from topup first if active let topupRemaining = usage.topup_tokens_remaining || 0; const topupExpired = !usage.topup_expires_at || new Date() > new Date(usage.topup_expires_at); if (topupExpired) topupRemaining = 0; await sbDB.update("user_usage", { month_start: monthStart, tokens_used_this_month: base + tokensUsed, topup_tokens_remaining: Math.max(0, topupRemaining - (topupExpired ? 0 : tokensUsed)), updated_at: new Date().toISOString(), }, { user_id: userId }); }, // Apply token top-up after payment applyTopup: async (userId) => { const expires = new Date(); expires.setDate(expires.getDate() + 30); await sbDB.update("user_usage", { topup_tokens_remaining: TOKEN_TOPUP_AMOUNT, topup_expires_at: expires.toISOString(), updated_at: new Date().toISOString(), }, { user_id: userId }); }, }; function getWeekStart() { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - d.getDay()); // Sunday return d.toISOString().split("T")[0]; } function getMonthStart() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`; } function getNextMonthReset() { const d = new Date(); const next = new Date(d.getFullYear(), d.getMonth() + 1, 1); return next.toLocaleString("en-US", { month: "long", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" }); } function getNextWeekReset() { const d = new Date(); const daysUntilSunday = 7 - d.getDay(); const next = new Date(d); next.setDate(d.getDate() + daysUntilSunday); next.setHours(0, 0, 0, 0); return next.toLocaleString("en-US", { weekday: "long", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" }); } function getDayStart() { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; } function getNextDayReset() { const next = new Date(); next.setDate(next.getDate() + 1); next.setHours(0, 0, 0, 0); return next.toLocaleString("en-US", { weekday: "long", month: "long", day: "numeric", hour: "numeric", minute: "2-digit", timeZoneName: "short" }); } function estimateTokens(text) { return Math.ceil((text || "").length / 4); } // ─── Free Session Limit Popup ───────────────────────────────────────────────── function ModuleCopyButton({ text }) { const [copied, setCopied] = React.useState(false); return ( ); } function FreeSessionLimitModal({ onUpgrade, onClose, userId }) { const resetTime = getNextDayReset(); return (
🏁
You've used your 5 free prompts today.
Free users get {FREE_MAX_PROMPTS_PER_DAY} prompts per day. Upgrade to Pro for unlimited access — no daily cap, ever.
WHAT YOU'RE MISSING ON PRO
{["🎭 Unlimited roleplay & objection drills", "📊 Close Intelligence Score (0–100)", "📞 Live call coaching & transcript analysis", "🏆 Full deal simulations from intro to close"].map((f, i) => (
{f}
))}
🔄 Your free prompts reset on {resetTime}
); } // ─── Free Objection Limit Popup ─────────────────────────────────────────────── function FreeObjLimitModal({ onUpgrade, onClose, userId }) { const resetTime = getNextWeekReset(); return (
🎯
Session complete — nice work!
You've practiced {FREE_MAX_OBJECTIONS_PER_SESSION} objections this session — that's your free limit. Start a new session or go Pro for unlimited practice.
🔄 Sessions reset {resetTime}
); } // ─── Pro Token Warning Popup (within 10k of limit) ──────────────────────────── function ProTokenWarningModal({ tokensUsed, onUpgrade, onClose, userId }) { const remaining = PRO_MAX_TOKENS_PER_MONTH - tokensUsed; const resetTime = getNextMonthReset(); return (
Running low on tokens
You've used {tokensUsed.toLocaleString()} of your 300,000 monthly tokens. Only ~{remaining.toLocaleString()} left this month.
💡 Add 300,000 more tokens for just $19 — one-time, no subscription. Valid for 30 days from purchase.
🔄 Your token allowance resets on {resetTime}
); } // ─── Pro Token Hard Limit Popup ─────────────────────────────────────────────── function ProTokenLimitModal({ onUpgrade, onClose, userId }) { const resetTime = getNextMonthReset(); return (
🔒
Monthly token limit reached
You've used your 300,000 token monthly allowance. Top up to keep training — it's a one-time $19 for another 300k.
TOKEN TOP-UP DETAILS
{[ "✓ +300,000 tokens added instantly", "✓ Valid for 30 days from purchase", "✓ Resume access immediately after payment", "✓ One-time charge — no recurring fees", ].map((f, i) => (
{f}
))}
🔄 Free reset on {resetTime}
); } // ─── System Prompts ──────────────────────────────────────────────────────────── const FREE_SYSTEM_PROMPT = `You are ${BRAND_CONFIG.appName} — a practical sales coaching assistant built for two types of salespeople: 1. IN-HOME / SHOWROOM SALES — Kitchen remodeling, flooring, roofing, windows, HVAC, home improvement. These are typically one-call close situations where the salesperson is in the home or showroom with a prospect. 2. SaaS / AE / REMOTE CLOSERS — Account Executives, SDRs, BDRs, remote closers selling software, services, or B2B solutions over the phone or video. You help with four core areas: ── OBJECTION HANDLING (ACRC Framework) ── When given an objection, respond using the ACRC framework: A — Agree: Show empathy and understanding, never argue. C — Clarify: Ask a short question to understand the real concern. R — Reframe: Shift focus to value, outcomes, or the cost of inaction. C — Close: Guide toward the next step naturally. Always give exact words they can say. Make it sound human and natural, not scripted. ── SCRIPT REWRITING ── When asked to improve a script or message, provide: • BEST VERSION — strong, natural, persuasive rewrite Then ask: "Want an alternate version with a different angle or tone?" ── DISCOVERY QUESTIONS ── Generate 5–7 concise discovery questions tailored to their product/industry. Cover: pain points, goals, budget, timeline, decision process, current solutions. Offer to generate more if needed. ── INTERVIEW PREP ── Help salespeople prepare for sales job interviews with confident, recruiter-friendly answers. ── FAST COMMANDS ── Recognize and respond to these shorthand commands instantly: • ACRC: [objection] — Handle the objection using ACRC • SCRIPT: [message] — Rewrite the script • DISCOVERY: [product or situation] — Generate discovery questions • INTERVIEW: [question] — Give a strong interview answer ── RESPONSE STYLE ── Keep responses concise and mobile-friendly. Use short paragraphs. Lead with the most useful line first. Avoid long preambles or unnecessary intros. Be direct, confident, and specific. Never give vague or theoretical advice. You are a world-class sales coach.`; const PRO_SYSTEM_PROMPT = `You are ${BRAND_CONFIG.appName} Pro — an elite sales training and coaching system built specifically for two types of salespeople: 1. IN-HOME / SHOWROOM SALES — Kitchen remodeling, flooring, roofing, windows, HVAC, solar, home improvement. One-call close situations. Price objections, comparison shopping, quote hesitation, in-home pressure dynamics. 2. SaaS / AE / REMOTE CLOSERS — Account Executives, SDRs, BDRs, remote closers. Software demos, discovery calls, multi-stakeholder deals, pipeline management, follow-up sequences, competitive differentiation. You have three core systems: ── SYSTEM 1: TRAINING ── • Roleplay Simulator — Play a realistic buyer (skeptical homeowner, rushed executive, price shopper, analytical CFO, etc.). Stay fully in character until user says "Evaluate my responses." • Buyer Personality Modes — Skeptical Homeowner, Analytical CFO, Aggressive Price Shopper, Rushed Executive, Friendly but Non-Committal, Detail-Oriented Ops Leader • Full Deal Simulation — Run a complete sales cycle: Prospecting → Discovery → Demo/Presentation → Objections → Negotiation → Close • Objection Drill Mode — Rapid fire: give objection → user responds → you critique → you improve it • Objection Library — Price, Timing, Trust, Authority, Competition, Budget, Risk, Vendor Comparison • Industry Scenarios — In-home remodeling, SaaS, Roofing, HVAC, Solar, Marketing Agencies, Remote Closing • Framework Trainer — MEDDICC, SPIN Selling, Challenger Sale, Sandler, Consultative Selling, One Call Close ── SYSTEM 2: COACHING ── • Real-Time Call Coach — User describes what buyer just said, you give exact response + follow-up question + strategy • Transcript Analyzer — Analyze pasted call transcript: discovery depth, objection handling, talk ratio, missed opportunities, closing attempts • Deal Coach — Help with stalled deals: identify cause, recommend next move, suggest exact messaging • Deal Risk Analyzer — Identify risks: weak champion, no budget confirmed, unknown decision process, stalled procurement • Messaging Coach — Cold calls, voicemails, LinkedIn, email sequences, follow-ups, re-engagement • Competitive Battlecard — Train against competitor mentions with differentiation scripts • Cold Outreach Coach — User pastes a cold email or LinkedIn message. Score it 1–10, explain what's weak, then rewrite it. Score the rewrite too. Be blunt. • Tonality & Pacing Coach — User pastes a script, pitch, or response. Analyze tone: too pushy? too passive? too salesy? too robotic? Give specific word-level edits and explain why. Rate overall tonality. • Follow-Up Generator — User describes the call outcome and deal stage. Generate 3 tailored follow-up messages: email, text, and voicemail script. Each must be specific to what happened on the call, not generic. ── SYSTEM 3: PERFORMANCE ── • Performance Reports — Session summary, strengths, areas to improve, sample improved responses • Close Intelligence Score — Score 0–100 across 7 metrics: Confidence, Clarity, Objection Handling, Discovery Depth, Reframe Strength, Closing Strength, Natural Delivery • Practice Recommendations — Based on weaknesses, suggest targeted drills • Session Report Control — Manage and review session history ── SYSTEM 4: TEAM TOOLS (also available to Pro individuals) ── • Custom Persona Builder — User describes their real buyer (industry, title, objections, personality). Build a detailed custom persona they can roleplay against repeatedly. • Rep Onboarding Mode — Run a structured 5-session sales training sequence for a new rep. Session 1: Product & ICP. Session 2: Discovery. Session 3: Objection handling. Session 4: Full roleplay. Session 5: Final assessment with score. • Weekly Performance Summary — User describes their week (calls made, deals moved, objections hit). Generate a structured weekly summary with wins, losses, patterns, and top 3 focus areas for next week. Also support all FREE features: • ACRC objection handling • Script rewrites (Best + Alternate versions) • Discovery questions • Interview prep • Fast commands: ACRC:, SCRIPT:, DISCOVERY:, INTERVIEW:, OUTREACH:, FOLLOWUP:, TONE: Always stay in character during roleplays. Be direct, challenging, and realistic. When coaching, give specific, actionable language they can use immediately. You are the best sales coach in the world. ── RESPONSE STYLE ── Keep responses concise and mobile-friendly. Use short paragraphs. Lead with the most useful line first. Avoid long preambles or unnecessary intros. For script rewrites, give one strong Best Version first, then ask if they want an alternate. For discovery questions, generate 5–7 and offer more if needed.`; // ─── Data ────────────────────────────────────────────────────────────────────── const FREE_QUICK_PROMPTS = [ { label: "💬 Handle Objection", prompt: "ACRC: Your price is too high." }, { label: "✍️ Rewrite My Script", prompt: "SCRIPT: " }, { label: "🔎 Discovery Questions", prompt: "DISCOVERY: " }, { label: "💼 Interview Prep", prompt: "INTERVIEW: How do you handle objections?" }, { label: "🏠 In-Home Objection", prompt: "ACRC: I want to get more quotes before deciding." }, { label: "💻 SaaS Objection", prompt: "ACRC: We already have a solution in place." }, { label: "📩 Follow-Up Email", prompt: "SCRIPT: " }, { label: "🎯 Closing Language", prompt: "Give me 5 natural closing statements for my industry." }, ]; const PRO_QUICK_PROMPTS = [ { label: "🎭 Start Roleplay", prompt: "Start a roleplay scenario." }, { label: "⚡ Objection Drills", prompt: "Start objection drill mode." }, { label: "📨 Cold Outreach", prompt: "OUTREACH: [paste your cold email or LinkedIn message here]" }, { label: "📞 Follow-Up Generator", prompt: "FOLLOWUP: " }, { label: "🎙️ Tone Coach", prompt: "TONE: [paste your script or pitch here and I'll score it]" }, { label: "🥊 CFO Roleplay", prompt: "Start a roleplay with an analytical CFO who needs ROI justification." }, { label: "📋 Transcript Review", prompt: "I want to analyze a sales call transcript. Ready for me to paste it?" }, { label: "🏆 Close Intel Score", prompt: "Give me my Close Intelligence Score with detailed breakdown." }, ]; // ─── Section Page Data ──────────────────────────────────────────────────────── // Each section has subsections with descriptions + auto-prompts const SECTION_PAGES = { // FREE sections (1-4) 1: { id: 1, label: "Objections", icon: "01", proOnly: false, desc: "Handle any objection with the ACRC framework — Agree, Clarify, Reframe, Close.", subsections: [ { label: "ACRC Objection Handler", desc: "Use the full ACRC framework to craft a response to any objection your buyer throws at you.", prompt: "ACRC: Your price is too high." }, { label: "Price Objection", desc: "Get exact words to handle price pushback without caving on your numbers.", prompt: "ACRC: That's more than I wanted to spend." }, { label: "Think About It", desc: "Keep the deal alive when a prospect stalls with 'I need to think about it.'", prompt: "ACRC: I need to think about it." }, { label: "Getting More Quotes", desc: "Respond confidently when buyers want to shop around before deciding.", prompt: "ACRC: I'm getting more quotes before I decide." }, { label: "Send Me Info", desc: "Turn a brush-off request for information into a path toward a real conversation.", prompt: "ACRC: Just send me some information." }, { label: "Already Have a Vendor", desc: "Handle incumbency objections and open the door to a competitive conversation.", prompt: "ACRC: We already have a solution in place." }, ] }, 2: { id: 2, label: "Scripts", icon: "02", proOnly: false, desc: "Rewrite any message for clarity, persuasion, and natural delivery.", subsections: [ { label: "Rewrite My Script", desc: "Paste any script, pitch, or message and get a stronger Best Version and an Alternate Version.", prompt: "SCRIPT: " }, { label: "Follow-Up Message", desc: "Get a follow-up message that re-engages prospects who've gone quiet after a proposal.", prompt: "SCRIPT: " }, { label: "Cold Outreach", desc: "Generate a targeted cold outreach message for your product or service.", prompt: "SCRIPT: " }, { label: "Closing Statement", desc: "Get 3–5 natural, pressure-free closing statements you can use at the end of a consultation.", prompt: "Give me 5 natural closing statements for my industry." }, ] }, 3: { id: 3, label: "Discovery", icon: "03", proOnly: false, desc: "Generate powerful discovery questions tailored to any product or sales situation.", subsections: [ { label: "In-Home Remodeling", desc: "Get 6–10 discovery questions designed for in-home kitchen, flooring, or remodeling consultations.", prompt: "DISCOVERY: " }, { label: "SaaS / Software", desc: "Uncover pain, budget, timeline, and stakeholders on a software evaluation call.", prompt: "DISCOVERY: " }, { label: "Remote Closer", desc: "Discovery questions for high-ticket remote closing calls — designed to surface urgency and commitment.", prompt: "DISCOVERY: " }, { label: "Custom Discovery", desc: "Enter your product and situation and get discovery questions tailored specifically to your sales context.", prompt: "DISCOVERY: " }, ] }, 4: { id: 4, label: "Interview Prep", icon: "04", proOnly: false, desc: "Prepare for sales job interviews with confident, recruiter-ready answers.", subsections: [ { label: "Sales Process Question", desc: "Walk confidently through your sales process when asked to describe how you sell.", prompt: "INTERVIEW: Tell me about your sales process." }, { label: "Objection Handling Question", desc: "Answer objection-handling interview questions with a structured, impressive response.", prompt: "INTERVIEW: How do you handle objections?" }, { label: "Greatest Achievement", desc: "Frame your biggest sales win in a way that showcases skill, not luck.", prompt: "INTERVIEW: What's your greatest sales achievement?" }, { label: "Why Sales?", desc: "Answer the 'Why sales?' question with conviction and authenticity.", prompt: "INTERVIEW: Why do you want to be in sales?" }, ] }, // PRO sections (5-8) 5: { id: 5, label: "Training", icon: "05", proOnly: true, desc: "Roleplay simulations, objection drills, full deal cycles, and framework training.", subsections: [ { label: "Roleplay Simulator", desc: "Practice against a realistic AI buyer — skeptical homeowner, rushed executive, price shopper, and more.", prompt: "Start a roleplay scenario." }, { label: "Buyer Personalities", desc: "Choose a specific buyer personality type to practice against for targeted skill-building.", prompt: "Show me available buyer personality modes." }, { label: "Custom Persona Builder", desc: "Build a custom buyer persona based on your real prospects so you can practice the exact conversations you face.", prompt: "Help me build a custom buyer persona based on my real prospects." }, { label: "Full Deal Simulation", desc: "Run a complete sales cycle from prospecting through close in one immersive AI simulation.", prompt: "Start a full deal simulation." }, { label: "Objection Drill Mode", desc: "Rapid-fire objection practice: get an objection, respond, receive critique, see the improved version.", prompt: "Start objection drill mode." }, { label: "Objection Library", desc: "Train on specific objection categories: price, timing, trust, authority, competition, and more.", prompt: "Train me on price objections." }, { label: "Industry Scenarios", desc: "Practice industry-specific sales scenarios for remodeling, SaaS, roofing, HVAC, solar, and more.", prompt: "Show me available industry scenario packs." }, { label: "Framework Trainer", desc: "Train with top sales methodologies: MEDDICC, SPIN, Challenger, Sandler, Consultative Selling.", prompt: "Show me available sales frameworks to train with." }, { label: "Rep Onboarding", desc: "Complete the structured 5-session onboarding program: Product, Discovery, Objections, Roleplay, Final Assessment.", prompt: "Start the 5-session rep onboarding training program. I'm a new sales rep." }, ] }, 6: { id: 6, label: "Coaching", icon: "06", proOnly: true, desc: "Real-time call coaching, deal analysis, transcript review, and outreach feedback.", subsections: [ { label: "Cold Outreach Coach", desc: "Paste your cold email or LinkedIn message and get a score, a critique, and a rewritten version.", prompt: "OUTREACH: [paste your cold email or LinkedIn message here]" }, { label: "Tone & Pacing Coach", desc: "Paste any script or pitch and get a word-level tonality analysis — too pushy? too passive? fix it here.", prompt: "TONE: [paste your script or pitch and I'll score your tonality]" }, { label: "Follow-Up Generator", desc: "Describe your last call and get three tailored follow-ups: email, text, and voicemail.", prompt: "FOLLOWUP: [describe your last call and where the deal stands]" }, { label: "Real-Time Call Coach", desc: "Tell me exactly what the buyer just said and get the perfect response plus a follow-up question.", prompt: "I need real-time coaching. The buyer just said they want to compare vendors. What should I say?" }, { label: "Transcript Analyzer", desc: "Paste a call transcript and get a full breakdown: discovery depth, talk ratio, objection handling, missed opportunities.", prompt: "I want to analyze a sales call transcript. Ready for me to paste it?" }, { label: "Deal Coach", desc: "Describe a stalled or struggling deal and get a diagnosis, a next-step recommendation, and exact messaging.", prompt: "I need help with a stalled deal." }, { label: "Deal Risk Analyzer", desc: "Identify the hidden risks in any active deal — weak champion, unclear budget, stalled procurement, and more.", prompt: "Evaluate risk in my current deal." }, { label: "Messaging Coach", desc: "Get help writing cold calls, voicemails, LinkedIn messages, email sequences, and re-engagement outreach.", prompt: "Help me write outbound sales messaging." }, { label: "Competitive Battlecard", desc: "Train to respond when a buyer brings up a competitor — get differentiation scripts and positioning language.", prompt: "The buyer says they are considering a competitor. How should I respond?" }, ] }, 7: { id: 7, label: "Performance", icon: "07", proOnly: true, desc: "Close Intelligence Scores, performance reports, and personalized practice plans.", subsections: [ { label: "Close Intelligence Score", desc: "Get your CIS score (0–100) across 7 metrics: Confidence, Clarity, Objection Handling, Discovery, Reframe, Closing, and Delivery.", prompt: "Give me my Close Intelligence Score with detailed breakdown." }, { label: "Performance Report", desc: "Generate a session performance report with your strengths, areas to improve, and sample improved responses.", prompt: "Generate a performance report." }, { label: "Weekly Summary", desc: "Describe your week — calls made, deals moved, objections hit — and get a structured summary with top focus areas.", prompt: "Generate my weekly performance summary. I'll describe my week." }, { label: "Practice Recommendations", desc: "Get a personalized drill and practice plan based on your current weaknesses and performance patterns.", prompt: "What should I practice based on my weaknesses?" }, ] }, 8: { id: 8, label: "Team Tools", icon: "08", proOnly: true, desc: "Persona building, rep onboarding, and weekly team performance tracking.", subsections: [ { label: "Custom Persona Builder", desc: "Describe your real buyer — industry, title, typical objections, personality — and build a detailed persona to practice against.", prompt: "Help me build a custom buyer persona based on my real prospects." }, { label: "Rep Onboarding Mode", desc: "Run a structured 5-session training program for a new rep covering product, discovery, objections, roleplay, and final assessment.", prompt: "Start the 5-session rep onboarding training program. I'm a new sales rep." }, { label: "Weekly Performance Summary", desc: "Describe your team's week and get a structured summary with wins, patterns, and the top 3 coaching focus areas.", prompt: "Generate a weekly team performance summary. I'll describe the week." }, ] }, }; const FREE_SIDEBAR = [ { id: 1, label: "Objections", icon: "01" }, { id: 2, label: "Scripts", icon: "02" }, { id: 3, label: "Discovery", icon: "03" }, { id: 4, label: "Interview Prep", icon: "04" }, { id: 10, label: "Profit Engine", icon: "💰", isProfitEngine: true }, { id: 5, label: "Training", icon: "05", proOnly: true }, { id: 6, label: "Coaching", icon: "06", proOnly: true }, { id: 7, label: "Performance", icon: "07", proOnly: true }, { id: 9, label: "Close or Lose", icon: "⚔️", proOnly: true, isGame: true }, ]; const PRO_SIDEBAR = [ { id: 9, label: "Close or Lose", icon: "⚔️", isGame: true }, { id: 10, label: "Profit Engine", icon: "💰", isProfitEngine: true }, { id: 11, label: "Sales IQ", icon: "⚡", isSalesIQ: true }, { id: 5, label: "Training", icon: "01" }, { id: 6, label: "Coaching", icon: "02" }, { id: 7, label: "Performance", icon: "03" }, { id: 1, label: "Objections", icon: "04" }, { id: 2, label: "Scripts", icon: "05" }, { id: 3, label: "Discovery", icon: "06" }, { id: 4, label: "Interview Prep", icon: "07" }, { id: 8, label: "Team Tools", icon: "08" }, ]; // ─── Close or Lose — Seed Leaderboard ──────────────────────────────────────── const SEED_USERS = [ { name: "Marcus Webb", title: "Sr. Account Executive", location: "Dallas, TX", score: 94, scenarios: 31, winRate: 88 }, { name: "Priya Nair", title: "In-Home Sales Rep", location: "Phoenix, AZ", score: 91, scenarios: 28, winRate: 85 }, { name: "Tyler Johansson", title: "Remote Closer", location: "Austin, TX", score: 90, scenarios: 26, winRate: 84 }, { name: "Danielle Cross", title: "SaaS Account Executive", location: "Chicago, IL", score: 89, scenarios: 24, winRate: 82 }, { name: "Kevin Okafor", title: "Flooring Sales Consultant", location: "Atlanta, GA", score: 88, scenarios: 22, winRate: 81 }, { name: "Sofia Reyes", title: "Solar Sales Rep", location: "San Diego, CA", score: 87, scenarios: 29, winRate: 80 }, { name: "James Whitfield", title: "Enterprise AE", location: "New York, NY", score: 86, scenarios: 19, winRate: 79 }, { name: "Alicia Monroe", title: "Kitchen Remodel Specialist", location: "Houston, TX", score: 85, scenarios: 21, winRate: 78 }, { name: "Derek Sung", title: "SDR to AE", location: "Seattle, WA", score: 84, scenarios: 17, winRate: 77 }, { name: "Natasha Bright", title: "HVAC Sales Rep", location: "Denver, CO", score: 83, scenarios: 23, winRate: 76 }, { name: "Brandon Tate", title: "Roofing Sales Closer", location: "Nashville, TN", score: 82, scenarios: 18, winRate: 75 }, { name: "Camille Durant", title: "B2B Sales Executive", location: "Miami, FL", score: 81, scenarios: 15, winRate: 74 }, { name: "Josh Kimura", title: "Inside Sales Rep", location: "Portland, OR", score: 80, scenarios: 20, winRate: 73 }, { name: "Rachel Torres", title: "Window & Door Sales", location: "Las Vegas, NV", score: 79, scenarios: 14, winRate: 72 }, { name: "Antonio Ferreira", title: "Account Manager", location: "Boston, MA", score: 78, scenarios: 16, winRate: 71 }, { name: "Hannah Lowe", title: "Flooring Sales Rep", location: "Charlotte, NC", score: 77, scenarios: 12, winRate: 70 }, { name: "Miles Patterson", title: "Remote Sales Closer", location: "Tampa, FL", score: 76, scenarios: 13, winRate: 69 }, { name: "Zoe Chambers", title: "SaaS BDR", location: "Austin, TX", score: 75, scenarios: 11, winRate: 68 }, { name: "Chris Navarro", title: "Home Improvement Sales", location: "Sacramento, CA", score: 74, scenarios: 18, winRate: 67 }, { name: "Tiffany Brooks", title: "Insurance Sales Rep", location: "Columbus, OH", score: 73, scenarios: 10, winRate: 66 }, { name: "Elijah Grant", title: "Solar Consultant", location: "Scottsdale, AZ", score: 72, scenarios: 9, winRate: 65 }, { name: "Megan Schultz", title: "AE Mid Market", location: "Minneapolis, MN", score: 71, scenarios: 14, winRate: 64 }, { name: "Ryan Castillo", title: "Remodeling Sales Rep", location: "San Antonio, TX", score: 70, scenarios: 8, winRate: 63 }, { name: "Diana Pham", title: "Tech Sales Rep", location: "San Jose, CA", score: 69, scenarios: 11, winRate: 62 }, { name: "Noah Henderson", title: "HVAC Comfort Advisor", location: "Indianapolis, IN", score: 68, scenarios: 7, winRate: 61 }, { name: "Keisha Williams", title: "Direct Sales Rep", location: "Detroit, MI", score: 67, scenarios: 12, winRate: 60 }, { name: "Owen Fitzgerald", title: "Roofing Estimator", location: "Kansas City, MO", score: 66, scenarios: 6, winRate: 59 }, { name: "Leila Hassan", title: "SDR", location: "Austin, TX", score: 65, scenarios: 9, winRate: 58 }, { name: "Travis Moon", title: "Home Security Sales", location: "Salt Lake City, UT", score: 64, scenarios: 8, winRate: 57 }, { name: "Vanessa Cox", title: "Flooring Specialist", location: "Raleigh, NC", score: 63, scenarios: 7, winRate: 56 }, { name: "Adam Reyes", title: "Outside Sales Rep", location: "El Paso, TX", score: 62, scenarios: 5, winRate: 55 }, { name: "Brianna Scott", title: "Solar Sales Advisor", location: "Albuquerque, NM", score: 61, scenarios: 6, winRate: 54 }, { name: "Darius Cole", title: "Pest Control Sales", location: "Memphis, TN", score: 60, scenarios: 4, winRate: 53 }, { name: "Penny Larson", title: "Real Estate Agent", location: "Richmond, VA", score: 59, scenarios: 7, winRate: 52 }, { name: "Garrett Howell", title: "Windows Sales Rep", location: "Louisville, KY", score: 58, scenarios: 5, winRate: 51 }, { name: "Simone Archer", title: "Med Device Sales", location: "Cincinnati, OH", score: 57, scenarios: 6, winRate: 50 }, { name: "Luke Brennan", title: "In-Home Sales Rep", location: "Oklahoma City, OK", score: 56, scenarios: 4, winRate: 49 }, { name: "Tara Nguyen", title: "SaaS Sales Rep", location: "San Francisco, CA", score: 55, scenarios: 5, winRate: 48 }, { name: "Corey Banks", title: "Alarm Sales Rep", location: "Jacksonville, FL", score: 54, scenarios: 3, winRate: 47 }, { name: "Isabel Moreau", title: "Kitchen Sales Specialist", location: "New Orleans, LA", score: 53, scenarios: 4, winRate: 46 }, { name: "Jalen Pierce", title: "Auto Sales Rep", location: "Detroit, MI", score: 52, scenarios: 3, winRate: 45 }, { name: "Chloe Barrett", title: "B2B Sales Associate", location: "Pittsburgh, PA", score: 51, scenarios: 4, winRate: 44 }, { name: "Marcus Holt", title: "Remote Closer", location: "Boise, ID", score: 50, scenarios: 3, winRate: 43 }, { name: "Nina Caldwell", title: "Field Sales Rep", location: "Tucson, AZ", score: 49, scenarios: 2, winRate: 42 }, { name: "Reggie Stone", title: "Solar Rep", location: "Fresno, CA", score: 48, scenarios: 3, winRate: 41 }, { name: "Alexis Ford", title: "Inside Sales", location: "Omaha, NE", score: 47, scenarios: 2, winRate: 40 }, { name: "Dominic Shaw", title: "Remodeling Consultant", location: "Tulsa, OK", score: 46, scenarios: 2, winRate: 39 }, { name: "Jasmine Reid", title: "Sales Development Rep", location: "Richmond, VA", score: 45, scenarios: 2, winRate: 38 }, { name: "Brady Walsh", title: "Entry Level AE", location: "Buffalo, NY", score: 44, scenarios: 1, winRate: 37 }, { name: "Sierra Long", title: "New Sales Rep", location: "Springfield, IL", score: 43, scenarios: 1, winRate: 36 }, ]; function getGameWeekStart() { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - d.getDay()); return d.toISOString().split("T")[0]; } function getNextSundayReset() { const d = new Date(); const daysUntil = 7 - d.getDay(); const next = new Date(d); next.setDate(d.getDate() + daysUntil); next.setHours(0, 0, 0, 0); return next.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" }); } // ─── Close or Lose v2 — Configs ────────────────────────────────────────────── const COL_INDUSTRIES = [ { id: "saas", label: "SaaS / Tech", icon: "💻" }, { id: "solar", label: "Solar", icon: "☀️" }, { id: "remodeling", label: "In-Home Remodeling", icon: "🏠" }, { id: "roofing", label: "Roofing", icon: "🏗️" }, { id: "insurance", label: "Insurance", icon: "🛡️" }, { id: "other", label: "Other", icon: "💼" }, ]; const COL_ROLES = [ { id: "ae", label: "Account Executive" }, { id: "sdr", label: "SDR / BDR" }, { id: "remote_closer", label: "Remote Closer" }, { id: "in_home", label: "In-Home Closer" }, { id: "appointment_setter", label: "Appointment Setter" }, { id: "other", label: "Other" }, ]; const COL_PERSONALITIES = [ { id: "analytical", label: "Analytical", desc: "Asks for data, ROI, proof" }, { id: "skeptical", label: "Skeptical", desc: "Questions everything" }, { id: "friendly", label: "Friendly", desc: "Warm but non-committal" }, { id: "dominant", label: "Dominant", desc: "Challenges claims directly" }, { id: "price_sensitive", label: "Price Sensitive", desc: "Every conversation returns to cost" }, ]; // Mode 1 – Speed Close rounds const MODE1_ROUNDS = [ { round: 1, difficulty: "Soft Hesitation", diffColor: "#22c55e", label: "Round 1", objection: "This sounds interesting but I don't think the timing is right for us.", skillTested: "Uncovering the real hesitation and creating urgency without pressure." }, { round: 2, difficulty: "Price Concern", diffColor: "#3b82f6", label: "Round 2", objection: "The price is higher than what we budgeted for this.", skillTested: "Reframing value against cost without discounting." }, { round: 3, difficulty: "Competitor Comparison", diffColor: "#f59e0b", label: "Round 3", objection: "We're already in conversations with two of your competitors and they're cheaper.", skillTested: "Differentiating specifically without attacking the competition." }, { round: 4, difficulty: "Decision Delay", diffColor: "#f97316", label: "Round 4", objection: "We like what we see but we need to think it over and loop in a few more people.", skillTested: "Pulling the decision forward and identifying the real stall." }, { round: 5, difficulty: "Boss Round", diffColor: "#ef4444", label: "Boss Round", objection: "The price is too high, we need leadership approval, and honestly I'm not sure this is the right time.", skillTested: "Handling three objections at once and landing a confident close." }, ]; // Mode 2 – Full Dialogue rounds (multi-turn) const MODE2_ROUNDS = [ { round: 1, difficulty: "Discovery Resistance", diffColor: "#22c55e", label: "Round 1", openingObjection: "We're not really looking to make any changes right now. Things are working fine.", followUps: [ "I hear you but I don't see a compelling reason to switch from what we have.", "What makes you think we have a problem that needs solving?", "We've looked at solutions like this before and it never worked out.", "Even if there's a better way, the disruption of changing isn't worth it right now.", "Our team is already stretched thin. Adding something new would just create more work.", ], skillsTested: ["Discovery questions", "Establishing curiosity", "Early conversation control"], maxExchanges: 3, }, { round: 2, difficulty: "Objection Battle", diffColor: "#f59e0b", label: "Round 2", openingObjection: "Your price is higher than what we're paying now and higher than what your competitors quoted us.", followUps: [ "I'd need to justify this cost increase to my CFO and I'm not sure I can.", "Unless you can match the price, I'm not sure there's a deal here.", "What exactly am I paying more for? Because on paper it looks the same.", "Our budget is locked until next quarter. Even if I wanted to move forward I couldn't.", "I've been burned before by paying more for something that didn't deliver. Why is this different?", ], skillsTested: ["Layered objection handling", "Maintaining authority", "Value positioning"], maxExchanges: 4, }, { round: 3, difficulty: "Closing Scenario", diffColor: "#ef4444", label: "Boss Round", openingObjection: "We appreciate the pitch but the timing isn't right. We're in the middle of a budget freeze.", followUps: [ "Even if we wanted to move forward, getting leadership to approve this right now would be a fight.", "A competitor offered us a better price and said they can wait until next quarter.", "I don't have the authority to sign off on this. It needs to go up the chain and that takes time.", "Look, I like what you're offering but I can't commit today. That's just not happening.", "We've had vendors push us to move fast before and it always ends badly. I'm not doing that again.", "Send me something in writing and I'll share it with the team. But don't expect a quick answer.", ], skillsTested: ["Confidence under pressure", "Multi-objection management", "Closing the deal"], maxExchanges: 6, }, ]; function getPersonalizedObjection(objection, industry, personality) { const industryCtx = { saas: "software solution", solar: "solar system", remodeling: "remodeling project", roofing: "roofing job", insurance: "insurance policy", other: "product", }; const ctx = industryCtx[industry] || "product"; let personalized = objection.replace(/product|solution|system/gi, ctx); // Personality flavoring — only applied when the objection doesn't already // contain a personality-specific phrase, so it never repeats the same suffix. const analyticalSuffixes = [ " What does the data actually support?", " I need to see the numbers before I can commit.", " Do you have case studies to back that up?", " What's the measurable ROI here?", " Walk me through the specifics.", ]; const dominantSuffixes = [ ", and I've seen better offers.", " Don't waste my time.", " I need a reason to move forward today.", " Make your case quickly.", " I'm not easily convinced.", ]; const priceSuffixes = [ " I need to know I'm getting the best deal possible.", " I've been quoted lower elsewhere.", " What's the best you can do on price?", " I'm not paying more than I have to.", " Price is my number one concern here.", ]; const alreadyFlavored = (suffix) => suffixes => suffixes.some(s => personalized.includes(s.trim())); if (personality === "analytical" && !analyticalSuffixes.some(s => personalized.includes(s.trim()))) { personalized += analyticalSuffixes[Math.floor(Math.random() * analyticalSuffixes.length)]; } if (personality === "dominant" && !dominantSuffixes.some(s => personalized.includes(s.trim()))) { const suffix = dominantSuffixes[Math.floor(Math.random() * dominantSuffixes.length)]; personalized = personalized.replace(/\.$/, "") + suffix; } if (personality === "price_sensitive" && !priceSuffixes.some(s => personalized.includes(s.trim()))) { personalized += priceSuffixes[Math.floor(Math.random() * priceSuffixes.length)]; } return personalized; } // ─── Profile Setup Modal ────────────────────────────────────────────────────── function GameProfileModal({ onSave, onCancel, existing }) { const [name, setName] = useState(existing?.name || ""); const [title, setTitle] = useState(existing?.title || ""); const [location, setLocation] = useState(existing?.location || ""); const [photo, setPhoto] = useState(existing?.photo || null); const fileRef = useRef(null); const handlePhoto = (e) => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => setPhoto(ev.target.result); reader.readAsDataURL(file); }; const canSave = name.trim() && title.trim() && location.trim(); return (
{onCancel && ( )}
⚔️
{existing ? "Edit Your Profile" : "Set Up Your Player Profile"}
{existing ? "Update your name, title, or location." : "This will appear on your scorecard when you share your results."}
fileRef.current?.click()} style={{ width: 72, height: 72, borderRadius: "50%", background: "#f8fafc", border: "2px dashed #cbd5e1", display: "flex", alignItems: "center", justifyContent: "center", cursor: "pointer", overflow: "hidden" }}> {photo ? profile : 📷}
Tap to add a photo (optional)
{[ { label: "FULL NAME", val: name, set: setName, placeholder: "e.g. James Carter" }, { label: "JOB TITLE", val: title, set: setTitle, placeholder: "e.g. Enterprise Account Executive" }, { label: "LOCATION", val: location, set: setLocation, placeholder: "e.g. Austin, TX" }, ].map((f, i) => (
{f.label}
f.set(e.target.value)} placeholder={f.placeholder} style={{ width: "100%", padding: "10px 14px", borderRadius: 8, border: "1px solid #e2e8f0", background: "#f8fafc", color: "#0f172a", fontSize: 13, fontFamily: "'Inter', sans-serif", outline: "none", boxSizing: "border-box" }} />
))}
); } // ─── Mode Selection Screen ──────────────────────────────────────────────────── function ModeSelectScreen({ onSelect, onExit, topScore, totalPlayers, isPro, onViewLeaderboard }) { const modes = [ { id: 1, icon: "⚡", name: "Speed Close", tag: "5 rounds · Fast", desc: "One objection per round. Score each response. See how you stack up.", color: "#3b82f6", free: false }, { id: 2, icon: "⚔️", name: "Close or Lose", tag: "3 rounds · Full simulation", desc: "Multi-turn conversations with a live AI buyer. Handle every layer.", color: "#f97316", free: false }, { id: 3, icon: "🔥", name: "Streak Mode", tag: "PRO · Endless", desc: "Win back-to-back deals. One loss resets your streak.", color: "#ef4444", free: false, proOnly: true }, ]; return (
⚔️ Close or Lose PRO
Choose Your Mode
Top score this week: {topScore} · {totalPlayers}+ reps competing
{modes.map((m) => ( ))}
); } // ─── Game Setup Screen (Industry + Role + Personality) ──────────────────────── function GameSetupScreen({ mode, onStart, onBack }) { const [industry, setIndustry] = useState(null); const [role, setRole] = useState(null); const [personality] = useState(() => COL_PERSONALITIES[Math.floor(Math.random() * COL_PERSONALITIES.length)]); const canStart = industry && role; return (
⚔️ {mode === 1 ? "⚡ Speed Close" : mode === 2 ? "⚔️ Close or Lose" : "🔥 Streak Mode"} — Setup
{/* Industry */}
YOUR INDUSTRY
{COL_INDUSTRIES.map(ind => ( ))}
{/* Role */}
YOUR ROLE
{COL_ROLES.map(r => ( ))}
{/* Personality reveal */}
YOUR BUYER TODAY
🎭
{personality.label} Buyer
{personality.desc}
); } // ─── Close or Lose — Main Game ──────────────────────────────────────────────── function CloseOrLoseGame({ currentUser, onExit, isPro }) { const [profile, setProfile] = useState(() => { try { return JSON.parse(localStorage.getItem("col_profile") || "null"); } catch { return null; } }); const [screen, setScreen] = useState("modeSelect"); // modeSelect | setup | playing | scoring | scorecard | leaderboard const [selectedMode, setSelectedMode] = useState(null); // 1 | 2 | 3 const [setupConfig, setSetupConfig] = useState(null); // { industry, role, personality } // Game state const [currentRound, setCurrentRound] = useState(0); const [userResponse, setUserResponse] = useState(""); const [roundScores, setRoundScores] = useState([]); const [roundFeedback, setRoundFeedback] = useState(null); const [loading, setLoading] = useState(false); // Mode 2 dialogue state const [dialogueHistory, setDialogueHistory] = useState([]); // [{role, content}] const [exchangeCount, setExchangeCount] = useState(0); const [currentObjection, setCurrentObjection] = useState(""); const [dialogueFeedback, setDialogueFeedback] = useState(null); // Timer state const [timeLeft, setTimeLeft] = useState(60); const [timerActive, setTimerActive] = useState(false); const timerRef = useRef(null); // Streak state const [currentStreak, setCurrentStreak] = useState(() => { try { return parseInt(localStorage.getItem("col_streak") || "0"); } catch { return 0; } }); const [bestStreak, setBestStreak] = useState(() => { try { return parseInt(localStorage.getItem("col_best_streak") || "0"); } catch { return 0; } }); const [streakMode, setStreakMode] = useState(null); // null | 1 | 2 (which mode they chose in streak) // Leaderboard const [leaderboard, setLeaderboard] = useState([]); const [weeklyGames, setWeeklyGames] = useState(0); const [showLeaderboardScreen, setShowLeaderboardScreen] = useState(false); const textareaRef = useRef(null); const rounds = selectedMode === 2 ? MODE2_ROUNDS : MODE1_ROUNDS; const totalRounds = selectedMode === 2 ? 3 : 5; // Timer: Speed Close = 60s, Close or Lose + Streak = 120s const timerDuration = selectedMode === 1 ? 60 : 120; const currentRoundData = rounds[currentRound]; const totalScore = roundScores.length ? Math.round(roundScores.reduce((a, b) => a + b, 0) / roundScores.length) : 0; const topScore = leaderboard[0]?.score || 94; const totalPlayers = weeklyGames + Math.min(SEED_USERS_DAILY.length, Math.max(0, 50 - weeklyGames)); const myRank = leaderboard.findIndex(e => e.isMe) + 1; const topPct = myRank > 0 ? Math.max(1, Math.round((myRank / leaderboard.length) * 100)) : null; const handleProfileSave = (p) => { localStorage.setItem("col_profile", JSON.stringify(p)); setProfile(p); }; const [editingProfile, setEditingProfile] = useState(false); // Timer useEffect(() => { if (timerActive && screen === "playing") { timerRef.current = setInterval(() => { setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); setTimerActive(false); handleTimeout(); return 0; } return t - 1; }); }, 1000); } return () => clearInterval(timerRef.current); }, [timerActive, screen]); const startTimer = () => { setTimeLeft(timerDuration); setTimerActive(true); }; const stopTimer = () => { clearInterval(timerRef.current); setTimerActive(false); }; const handleTimeout = async () => { const timeout = { overallScore: 0, relevance: 0, salesControl: 0, objectionHandling: 0, confidence: 0, progressToClose: 0, verdict: "Time expired — Deal Lost.", topStrength: "You stayed in the game", topWeakness: "Response speed needs work", coachNote: "In live sales, hesitation kills deals. Practice responding faster — buyers lose confidence when you pause too long." }; const newScores = [...roundScores, 0]; setRoundFeedback(timeout); setRoundScores(newScores); setScreen("scoring"); if (selectedMode === 3) handleStreakResult(false); const isLastRound = currentRound >= totalRounds - 1; if (isLastRound) { await submitScore(newScores); await loadLeaderboard(); } }; const loadLeaderboard = async () => { const weekStart = getGameWeekStart(); try { const res = await fetch(`${SUPABASE_URL}/rest/v1/col_scores?week_start=eq.${weekStart}&order=score.desc&limit=50`, { headers: { "apikey": SUPABASE_ANON_KEY, "Authorization": `Bearer ${SUPABASE_ANON_KEY}` } }); const real = res.ok ? await res.json() : []; setWeeklyGames(real.length); const seedsNeeded = Math.max(0, 50 - real.length); const seeds = SEED_USERS_DAILY.slice(0, seedsNeeded).map(s => ({ ...s, isSeed: true })); const combined = [ ...real.map(r => ({ name: r.player_name, title: r.job_title, location: r.location, score: r.score, game_mode: r.game_mode, isSeed: false, isMe: r.user_id === currentUser?.id })), ...seeds, ].sort((a, b) => b.score - a.score).slice(0, 50); setLeaderboard(combined); } catch { setLeaderboard(SEED_USERS_DAILY.map(s => ({ ...s, isSeed: true }))); } }; useEffect(() => { loadLeaderboard(); }, []); const submitScore = async (scores) => { if (!currentUser || !profile) return; const finalScore = Math.round(scores.reduce((a, b) => a + b, 0) / scores.length); const wins = scores.filter(s => s >= 70).length; try { await fetch(`${SUPABASE_URL}/rest/v1/col_scores`, { method: "POST", headers: { "apikey": SUPABASE_ANON_KEY, "Authorization": `Bearer ${SUPABASE_ANON_KEY}`, "Content-Type": "application/json", "Prefer": "resolution=merge-duplicates" }, body: JSON.stringify({ user_id: currentUser.id, week_start: getGameWeekStart(), player_name: profile.name, job_title: profile.title, location: profile.location, score: finalScore, scenarios_completed: totalRounds, win_rate: Math.round((wins / totalRounds) * 100), game_mode: selectedMode, updated_at: new Date().toISOString() }) }); } catch {} }; const handleStreakResult = (won) => { if (won) { const newStreak = currentStreak + 1; setCurrentStreak(newStreak); localStorage.setItem("col_streak", String(newStreak)); if (newStreak > bestStreak) { setBestStreak(newStreak); localStorage.setItem("col_best_streak", String(newStreak)); } } else { setCurrentStreak(0); localStorage.setItem("col_streak", "0"); } }; // ── Mode 1 scoring const scoreRoundMode1 = async () => { if (!userResponse.trim() || loading) return; // Enforce minimum meaningful response length if (userResponse.trim().length < 30) { setRoundFeedback({ overallScore: 10, relevance: 10, salesControl: 10, objectionHandling: 10, confidence: 10, progressToClose: 10, verdict: "Too short to score. A real buyer would have walked.", topStrength: "You responded", topWeakness: "One sentence doesn't close deals", coachNote: "Buyers expect a real response. Give them something specific — acknowledge what they said, reframe the value, and ask for the next step. At least 2-3 sentences." }); setRoundScores(prev => [...prev, 10]); setScreen("scoring"); if (selectedMode === 3) handleStreakResult(false); const isLast = currentRound >= totalRounds - 1; if (isLast) { await submitScore([...roundScores, 10]); await loadLeaderboard(); } return; } stopTimer(); setLoading(true); const r = currentRoundData; const objection = currentObjection || (setupConfig ? getPersonalizedObjection(r.objection, setupConfig.industry, setupConfig.personality) : r.objection); const industryLabel = COL_INDUSTRIES.find(i => i.id === setupConfig?.industry)?.label || "general sales"; const roleLabel = COL_ROLES.find(r => r.id === setupConfig?.role)?.label || "Sales Rep"; const personalityLabel = COL_PERSONALITIES.find(p => p.id === setupConfig?.personality)?.label || "Standard"; // Per-round difficulty context — escalates across the 5 rounds const roundDiffMap = [ { label: "Round 1 — Warm-Up", threshold: 65, pressure: "This is Round 1. Be honest but not brutal. A decent response should pass. Generic filler still fails, but a solid attempt earns it." }, { label: "Round 2 — Building", threshold: 68, pressure: "Round 2. Raise your standards. The rep should be finding their rhythm. Mediocre reframes no longer pass." }, { label: "Round 3 — Mid-Point", threshold: 70, pressure: "Round 3. This is the midpoint. Only responses that specifically address the objection, show real value understanding, AND guide toward a next step score 70+." }, { label: "Round 4 — Pressure", threshold: 72, pressure: "Round 4. High pressure. A score of 70+ requires the rep to handle the objection precisely, reframe with specificity, AND attempt a confident close. Generic language = deal lost." }, { label: "Boss Round — Final", threshold: 75, pressure: "This is the BOSS ROUND. Be brutally honest. Only an elite response handles all layers and earns 70+. A response that handles 2 of 3 objection layers scores 50-65 max. Vague or partial? 20-45." }, ]; const roundCtx = roundDiffMap[Math.min(currentRound, 4)]; // Streak mode: add extra pressure based on current streak length const streakPressure = selectedMode === 3 && currentStreak >= 3 ? `The rep is on a ${currentStreak}-game streak. Hold them to a higher standard — streak reps get complacent. Be especially critical of any response that feels like a formula.` : ""; const prompt = `You are a strict sales coach scoring a rep in the "Speed Close" game (Mode 1). Industry: ${industryLabel} | Role: ${roleLabel} | Buyer Personality: ${personalityLabel} ${roundCtx.label} of 5 — Difficulty: "${r.difficulty}" Objection: "${objection}" Rep's response: "${userResponse}" ROUND CONTEXT: ${roundCtx.pressure} ${streakPressure} UNIVERSAL SCORING RULES — NEVER BREAK THESE: - Generic, vague, or filler responses ("I understand", "great question", "we can help", "I hear you") MUST score 20-40. No exceptions. - Responses under 2 meaningful sentences: score 15-35. - A response that only restates the problem without reframing or advancing: 30-50. - 70+ threshold for this round: ${roundCtx.threshold}. A score of 70+ means the rep CLOSED THE DEAL. - The overallScore must equal the mathematical average of the five metric scores. - coachNote must be specific: quote the rep's actual words and explain exactly what worked or failed. No generic praise. Score on FIVE weighted metrics (0-100 each). Respond ONLY in this exact JSON (no markdown, no preamble): {"overallScore":85,"relevance":85,"salesControl":80,"objectionHandling":88,"confidence":82,"progressToClose":80,"verdict":"One sentence verdict.","topStrength":"Specific thing they did well","topWeakness":"Specific thing to improve","coachNote":"Blunt, specific coaching note quoting their actual language."}`; try { const res = await fetch("https://nahextkqnsmluuskjxps.supabase.co/functions/v1/anthropic-proxy", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${SUPABASE_ANON_KEY}` }, body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 400, messages: [{ role: "user", content: prompt }] }) }); const data = await res.json(); const text = (data.content || []).map(c => c.text || "").join("").replace(/```json|```/g, "").trim(); const parsed = JSON.parse(text); const newScores = [...roundScores, parsed.overallScore]; setRoundFeedback(parsed); setRoundScores(newScores); setScreen("scoring"); const won = parsed.overallScore >= 70; if (selectedMode === 3) handleStreakResult(won); const isLast = currentRound >= totalRounds - 1; if (isLast) { await submitScore(newScores); await loadLeaderboard(); } } catch { const fb = { overallScore: 15, relevance: 10, salesControl: 15, objectionHandling: 15, confidence: 12, progressToClose: 10, verdict: "Scoring failed — response couldn't be evaluated.", topStrength: "You responded", topWeakness: "Response wasn't specific enough to score", coachNote: "Something went wrong scoring your response. Make sure your answer directly addresses the objection, reframes the value, and asks for a commitment." }; const newScores = [...roundScores, 15]; setRoundFeedback(fb); setRoundScores(newScores); setScreen("scoring"); if (selectedMode === 3) handleStreakResult(false); const isLast = currentRound >= totalRounds - 1; if (isLast) { await submitScore(newScores); await loadLeaderboard(); } } setLoading(false); }; // ── Mode 2 — start a round (set opening objection) const startMode2Round = () => { const r = currentRoundData; const objection = setupConfig ? getPersonalizedObjection(r.openingObjection, setupConfig.industry, setupConfig.personality) : r.openingObjection; setCurrentObjection(objection); setDialogueHistory([{ role: "buyer", content: objection }]); setExchangeCount(0); setDialogueFeedback(null); setScreen("playing"); startTimer(); }; // ── Mode 2 — submit a dialogue exchange const submitDialogueResponse = async () => { if (!userResponse.trim() || loading) return; stopTimer(); setLoading(true); const r = currentRoundData; const newHistory = [...dialogueHistory, { role: "rep", content: userResponse }]; setDialogueHistory(newHistory); setUserResponse(""); const newExchangeCount = exchangeCount + 1; setExchangeCount(newExchangeCount); const isFinalExchange = newExchangeCount >= r.maxExchanges; const industryLabel = COL_INDUSTRIES.find(i => i.id === setupConfig?.industry)?.label || "general sales"; const personalityLabel = COL_PERSONALITIES.find(p => p.id === setupConfig?.personality)?.label || "Standard"; if (isFinalExchange) { // Score the full dialogue const historyText = newHistory.map(m => `${m.role === "buyer" ? "Buyer" : "Rep"}: ${m.content}`).join("\n"); const mode2RoundCtx = [ { label: "Round 1 — Discovery Resistance", threshold: 65, pressure: "Round 1. The rep needs to establish curiosity and open the buyer up. A decent attempt earns it. Generic openers still fail." }, { label: "Round 2 — Objection Battle", threshold: 70, pressure: "Round 2. The rep should be showing real value-positioning skills now. Reframes must be specific. Blanket statements score 30-50." }, { label: "Round 3 — Boss Round", threshold: 75, pressure: "BOSS ROUND. Multi-layered situation requiring confidence, specificity, and a real close. Be brutal. Only elite dialogue earns 70+. Partial handling = 45-65 max." }, ][Math.min(currentRound, 2)]; const streakBonus = selectedMode === 3 && currentStreak >= 3 ? `Rep is on a ${currentStreak}-game streak — apply extra scrutiny. Don't reward formula responses.` : ""; const prompt = `You are a strict sales coach scoring a full sales dialogue in the "Close or Lose" game (Mode 2). Industry: ${industryLabel} | Buyer: ${personalityLabel} | ${mode2RoundCtx.label} Full conversation: ${historyText} ROUND CONTEXT: ${mode2RoundCtx.pressure} ${streakBonus} SCORING RULES: - Generic, vague, or non-committal rep responses throughout the conversation MUST result in an overallScore of 20-45. - If the rep failed to handle objections specifically, reframe value, or ask for commitment: 30-55. - 70+ threshold this round: ${mode2RoundCtx.threshold}. Requires the rep to have consistently addressed objections, reframed, and guided toward a real close. - The overallScore must equal the mathematical average of the five metric scores. - coachNote must reference specific exchanges in the conversation — quote actual lines. No vague feedback. Score on five metrics (0-100 each). Respond ONLY in this exact JSON (no markdown): {"overallScore":80,"relevance":78,"salesControl":82,"objectionHandling":80,"confidence":79,"progressToClose":81,"verdict":"One sentence verdict.","topStrength":"Specific strength from the dialogue","topWeakness":"Specific weakness from the dialogue","coachNote":"Blunt coaching note citing specific exchanges."}`; try { const res = await fetch("https://nahextkqnsmluuskjxps.supabase.co/functions/v1/anthropic-proxy", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${SUPABASE_ANON_KEY}` }, body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 500, messages: [{ role: "user", content: prompt }] }) }); const data = await res.json(); const text = (data.content || []).map(c => c.text || "").join("").replace(/```json|```/g, "").trim(); const parsed = JSON.parse(text); const newScores = [...roundScores, parsed.overallScore]; setRoundFeedback(parsed); setRoundScores(newScores); setScreen("scoring"); const won = parsed.overallScore >= 70; if (selectedMode === 3) handleStreakResult(won); const isLast = currentRound >= totalRounds - 1; if (isLast) { await submitScore(newScores); await loadLeaderboard(); } } catch { const fb = { overallScore: 15, relevance: 12, salesControl: 15, objectionHandling: 14, confidence: 13, progressToClose: 10, verdict: "Scoring failed — dialogue couldn't be evaluated.", topStrength: "You stayed in the conversation", topWeakness: "Responses didn't build enough momentum to score", coachNote: "Something went wrong evaluating this dialogue. In a real multi-turn close, every exchange must move the buyer forward — address each objection specifically and push for commitment." }; const newScores = [...roundScores, 15]; setRoundFeedback(fb); setRoundScores(newScores); setScreen("scoring"); const isLast = currentRound >= totalRounds - 1; if (isLast) { await submitScore(newScores); await loadLeaderboard(); } } } else { // Get buyer's next response — AI-generated, reactive to what the rep actually said const historyText = newHistory.map(m => `${m.role === "buyer" ? "Buyer" : "Rep"}: ${m.content}`).join("\n"); const personalityLabel = COL_PERSONALITIES.find(p => p.id === setupConfig?.personality)?.label || "Skeptical"; const industryLabel = COL_INDUSTRIES.find(i => i.id === setupConfig?.industry)?.label || "general sales"; const buyerIntensity = currentRound === 0 ? "You are resistant but not hostile. You've heard pitches before and you're skeptical. You need a real reason to engage." : currentRound === 1 ? "You are tough and price-focused. You've been burned by vendors before. A weak answer gets a harder counter. A strong answer gets acknowledgment — but one more hard condition." : "You are the BOSS ROUND buyer. You are experienced, skeptical, and in control of this conversation. Weak answers get shut down with a harder objection. Strong answers get acknowledged with one final high-stakes condition. You do NOT cave easily."; const exchangePressure = newExchangeCount >= r.maxExchanges - 1 ? "This is the FINAL exchange. If the rep gave a truly compelling close with a specific ask for commitment, you may cautiously signal openness — but state one final condition. If they were weak, shut it down definitively." : newExchangeCount >= Math.floor(r.maxExchanges / 2) ? "You're halfway through this conversation. Raise your resistance level — ask a harder question or surface a new concern the rep hasn't addressed." : "Early in the conversation. You are resistant. A generic opener gets a harder objection. A good one gets a skeptical question."; const streakBuyerNote = selectedMode === 3 && currentStreak >= 3 ? ` The rep is on a ${currentStreak}-game streak. They may be getting formulaic. Be especially hard on responses that feel scripted.` : ""; const buyerPrompt = `You are a ${personalityLabel} buyer in a ${industryLabel} sales conversation. ${buyerIntensity}${streakBuyerNote} Conversation so far: ${historyText} Your role in this exchange: ${exchangePressure} Respond as this buyer. Hard rules: - Generic rep response ("I understand", "great question", "we can definitely help") → escalate immediately. Add a sharper, more specific objection. Show frustration. - Somewhat specific but incomplete rep response → acknowledge minimally, then surface a harder follow-on concern. - Strong, specific, confident rep response with real reasoning → acknowledge it briefly but still press with one realistic concern or harder question. - Never make it easy. A rep who handles this well still has to work for it. - Sound like a real human. 1-3 sentences max. No "Buyer:" prefix.`; try { const res = await fetch("https://nahextkqnsmluuskjxps.supabase.co/functions/v1/anthropic-proxy", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${SUPABASE_ANON_KEY}` }, body: JSON.stringify({ model: "claude-sonnet-4-6", max_tokens: 150, messages: [{ role: "user", content: buyerPrompt }] }) }); const data = await res.json(); const buyerReply = (data.content || []).map(c => c.text || "").join("").trim(); if (buyerReply && buyerReply.length > 5) { const nextHistory = [...newHistory, { role: "buyer", content: buyerReply }]; setDialogueHistory(nextHistory); setCurrentObjection(buyerReply); } else { throw new Error("Empty buyer reply"); } } catch { // Fallback to static follow-up if AI call fails or returns empty // Pick randomly from the pool so it never feels like a predictable queue const followUpPool = r.followUps; const followUp = followUpPool[Math.floor(Math.random() * followUpPool.length)]; const personalizedFollowUp = setupConfig ? getPersonalizedObjection(followUp, setupConfig.industry, setupConfig.personality) : followUp; const nextHistory = [...newHistory, { role: "buyer", content: personalizedFollowUp }]; setDialogueHistory(nextHistory); setCurrentObjection(personalizedFollowUp); } startTimer(); } setLoading(false); }; const nextRound = () => { if (currentRound < totalRounds - 1) { const nextRoundIndex = currentRound + 1; setCurrentRound(nextRoundIndex); setUserResponse(""); setRoundFeedback(null); setDialogueHistory([]); setExchangeCount(0); if (selectedMode === 2) { startMode2Round(); } else { // Set the next round's objection in state once so it never re-randomizes const r = MODE1_ROUNDS[nextRoundIndex]; const obj = getPersonalizedObjection(r.objection, setupConfig?.industry, setupConfig?.personality); setCurrentObjection(obj); setScreen("playing"); startTimer(); } } else { setScreen("scorecard"); } }; const startGame = (config) => { setSetupConfig(config); setCurrentRound(0); setRoundScores([]); setRoundFeedback(null); setUserResponse(""); setDialogueHistory([]); setExchangeCount(0); if (selectedMode === 2) { setScreen("playing"); const r = MODE2_ROUNDS[0]; const obj = getPersonalizedObjection(r.openingObjection, config.industry, config.personality); setCurrentObjection(obj); setDialogueHistory([{ role: "buyer", content: obj }]); startTimer(); } else { // Set Mode 1 objection once in state so it doesn't re-randomize on every render const r = selectedMode === 3 && streakMode === 2 ? MODE2_ROUNDS[0] : MODE1_ROUNDS[0]; const obj = getPersonalizedObjection(r.objection || r.openingObjection, config.industry, config.personality); setCurrentObjection(obj); setScreen("playing"); startTimer(); } }; const resetGame = () => { setScreen("modeSelect"); setSelectedMode(null); setSetupConfig(null); setCurrentRound(0); setRoundScores([]); setRoundFeedback(null); setUserResponse(""); setDialogueHistory([]); setExchangeCount(0); stopTimer(); }; const handleModeSelect = (modeId) => { if (modeId === 3) { // Streak mode — pick sub-mode first setSelectedMode(3); setScreen("streakModeSelect"); } else { setSelectedMode(modeId); setScreen("setup"); } }; if (!profile) return ; // ── LEADERBOARD SCREEN if (showLeaderboardScreen) return (
🏆 Weekly Leaderboard Resets {getNextSundayReset()}
{[ { label: "Top Score", val: leaderboard[0]?.score || "—", color: "#16a34a" }, { label: "Players This Week", val: `${totalPlayers}+`, color: "#2563eb" }, { label: "Your Rank", val: myRank > 0 ? `#${myRank}` : "—", color: "#16a34a" }, ].map((s, i) => (
{s.val}
{s.label}
))}
{leaderboard.map((entry, i) => { const medals = ["🥇", "🥈", "🥉"]; const scoreColor = i < 3 ? "#16a34a" : "#0f172a"; const modeMap = { 1: "⚡ Speed Close", 2: "⚔️ Close or Lose", 3: "🔥 Streak" }; const timerMap = { 1: "60s", 2: "2min", 3: "2min" }; const entryMode = modeMap[entry.game_mode] || "⚡ Speed Close"; const entryTimer = timerMap[entry.game_mode] || "60s"; return (
{i < 3 ? medals[i] : `#${i + 1}`}
{entry.name} {entry.isMe && YOU}
{entry.title} · {entry.location}
{entry.score}
{entryMode} {entryTimer}
); })}
); // ── MODE SELECT if (screen === "modeSelect") return ( setShowLeaderboardScreen(true)} /> ); // ── STREAK MODE — PICK SUB-MODE if (screen === "streakModeSelect") return (
🔥 Streak Mode — Choose Format
{/* Streak display */}
{[{ label: "CURRENT STREAK", val: currentStreak, color: "#ef4444" }, { label: "BEST STREAK", val: bestStreak, color: "#f97316" }, { label: "GLOBAL RANK", val: myRank > 0 ? `Top ${topPct}%` : "—", color: "#3b82f6" }].map((s, i) => (
{s.val}
{s.label}
))}
Win deals back-to-back. One loss resets your streak to zero.
{[{ id: 1, label: "⚡ Speed Close", desc: "Fast 5-round objection battles" }, { id: 2, label: "⚔️ Close or Lose", desc: "Full multi-turn deal simulations" }].map(m => ( ))}
); // ── SETUP if (screen === "setup") return ( setScreen(selectedMode === 3 ? "streakModeSelect" : "modeSelect")} /> ); // ── PLAYING — shared header const PlayingHeader = () => { const modeLabel = selectedMode === 1 ? "⚡ Speed Close" : selectedMode === 2 ? "⚔️ Close or Lose" : `🔥 Streak ×${currentStreak}`; const timerLabel = selectedMode === 1 ? "60s" : "2min"; const rData = currentRoundData; const pct = timeLeft / timerDuration; const radius = 20; const circ = 2 * Math.PI * radius; const dash = pct * circ; const isWarning = timeLeft <= timerDuration * 0.25 && timeLeft > timerDuration * 0.1; const isCritical = timeLeft <= timerDuration * 0.1; const ringColor = isCritical ? "#ef4444" : isWarning ? "#f59e0b" : "#22c55e"; const ringBg = isCritical ? "#fef2f2" : isWarning ? "#fffbeb" : "#f0fdf4"; const textColor = isCritical ? "#dc2626" : isWarning ? "#d97706" : "#16a34a"; const displayTime = timerDuration >= 120 ? `${Math.floor(timeLeft / 60)}:${String(timeLeft % 60).padStart(2, "0")}` : String(timeLeft); return (
{modeLabel} {timerLabel}
{Array.from({ length: totalRounds }).map((_, i) => (
))}
{/* SVG ring timer */}
= 120 ? 10 : 13, fontWeight: 800, color: textColor, lineHeight: 1 }}>{displayTime}
{/* Color-coded time label badge */}
{displayTime}
); }; // ── PLAYING — MODE 1 if (screen === "playing" && selectedMode !== 2) { const r = currentRoundData; const objection = currentObjection || (setupConfig ? getPersonalizedObjection(r.objection, setupConfig.industry, setupConfig.personality) : r.objection); const industryLabel = COL_INDUSTRIES.find(i => i.id === setupConfig?.industry)?.label || ""; return (
{r.difficulty} {r.label} · Round {currentRound + 1} of {totalRounds} {industryLabel && · {industryLabel}}
SKILL TESTED
{r.skillTested}
BUYER SAYS:
"{objection}"
YOUR RESPONSE — Close it.