> ## Documentation Index
> Fetch the complete documentation index at: https://guide.tipx.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Track Bonuses

> Complete each bonus and mark it done to stay on track.

export const BonusTrackerPanel = () => {
  const bookmakersList = [{
    id: "ladbrokes",
    name: "Ladbrokes",
    bonus: 50,
    logo: "/logo/apps/ladbrokes.jpg"
  }, {
    id: "betright",
    name: "Betright",
    bonus: 100,
    logo: "/logo/apps/betright.jpg"
  }, {
    id: "playup",
    name: "PlayUP",
    bonus: 100,
    logo: "/logo/apps/playup.png"
  }, {
    id: "tab",
    name: "Tab",
    bonus: 50,
    logo: "/logo/apps/tab.png"
  }, {
    id: "unibet",
    name: "UniBet",
    bonus: 50,
    logo: "/logo/apps/unibet.jpg"
  }];
  const phaseOneDiscordDeepLink = "discord://discord.com/invite/dTKESqPH2B";
  const phaseOneDiscordFallbackUrl = "https://discord.gg/dTKESqPH2B";
  const storageKey = "tipx_bonus_tracker_v1";
  const storageVersion = 1;
  const createDefaultBookmakers = () => {
    const bookmakers = {};
    bookmakersList.forEach(bookmaker => {
      bookmakers[bookmaker.id] = {
        signedUp: false,
        bonusCompleted: false
      };
    });
    return bookmakers;
  };
  const createDefaultTrackerState = () => ({
    version: storageVersion,
    updatedAt: new Date().toISOString(),
    bookmakers: createDefaultBookmakers()
  });
  const sanitizeStoredTracker = parsed => {
    if (!parsed || typeof parsed !== "object") return null;
    if (parsed.version !== storageVersion) return null;
    if (!parsed.bookmakers || typeof parsed.bookmakers !== "object") return null;
    const sanitizedBookmakers = createDefaultBookmakers();
    bookmakersList.forEach(bookmaker => {
      const stored = parsed.bookmakers[bookmaker.id];
      if (!stored || typeof stored !== "object") return;
      const signedUp = typeof stored.signedUp === "boolean" ? stored.signedUp : false;
      const bonusCompleted = typeof stored.bonusCompleted === "boolean" ? stored.bonusCompleted : false;
      sanitizedBookmakers[bookmaker.id] = {
        signedUp: signedUp || bonusCompleted,
        bonusCompleted
      };
    });
    return {
      version: storageVersion,
      updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : new Date().toISOString(),
      bookmakers: sanitizedBookmakers
    };
  };
  const [tracker, setTracker] = useState(() => createDefaultTrackerState());
  const [hasHydrated, setHasHydrated] = useState(false);
  const [storageNotice, setStorageNotice] = useState("");
  const [confettiBurstId, setConfettiBurstId] = useState(0);
  const confettiTimerRef = useRef(null);
  useEffect(() => {
    try {
      const raw = localStorage.getItem(storageKey);
      if (!raw) {
        setHasHydrated(true);
        return;
      }
      const parsed = JSON.parse(raw);
      const sanitized = sanitizeStoredTracker(parsed);
      if (sanitized) {
        setTracker(sanitized);
      } else {
        localStorage.removeItem(storageKey);
        setStorageNotice("Saved progress was invalid and has been reset.");
        setTracker(createDefaultTrackerState());
      }
    } catch (error) {
      console.error("BonusTrackerPanel load error:", error);
      setStorageNotice("Unable to load saved progress in this browser.");
      setTracker(createDefaultTrackerState());
    }
    setHasHydrated(true);
  }, []);
  useEffect(() => {
    if (!hasHydrated) return;
    try {
      localStorage.setItem(storageKey, JSON.stringify(tracker));
    } catch (error) {
      console.error("BonusTrackerPanel save error:", error);
      setStorageNotice("Unable to save progress in this browser right now.");
    }
  }, [tracker, hasHydrated, storageKey]);
  useEffect(() => {
    return () => {
      if (confettiTimerRef.current) clearTimeout(confettiTimerRef.current);
    };
  }, []);
  const triggerConfetti = () => {
    if (confettiTimerRef.current) clearTimeout(confettiTimerRef.current);
    setConfettiBurstId(Date.now());
    confettiTimerRef.current = setTimeout(() => {
      setConfettiBurstId(0);
      confettiTimerRef.current = null;
    }, 2100);
  };
  const updateBookmaker = (bookmakerId, nextStateOrUpdater) => {
    setTracker(previous => {
      const current = previous.bookmakers[bookmakerId] || ({
        signedUp: false,
        bonusCompleted: false
      });
      const nextState = typeof nextStateOrUpdater === "function" ? nextStateOrUpdater(current) : nextStateOrUpdater;
      return {
        version: storageVersion,
        updatedAt: new Date().toISOString(),
        bookmakers: {
          ...previous.bookmakers,
          [bookmakerId]: nextState
        }
      };
    });
  };
  const handleSignedUpToggle = bookmakerId => {
    updateBookmaker(bookmakerId, current => {
      const nextSignedUp = !current.signedUp;
      return {
        signedUp: nextSignedUp,
        bonusCompleted: nextSignedUp ? current.bonusCompleted : false
      };
    });
  };
  const handleBonusCompletedToggle = bookmakerId => {
    const current = tracker.bookmakers[bookmakerId] || ({
      signedUp: false,
      bonusCompleted: false
    });
    const nextCompleted = !current.bonusCompleted;
    if (nextCompleted) {
      triggerConfetti();
    }
    updateBookmaker(bookmakerId, previous => ({
      signedUp: nextCompleted ? true : previous.signedUp,
      bonusCompleted: nextCompleted
    }));
  };
  const totalBonus = bookmakersList.reduce((sum, bookmaker) => sum + bookmaker.bonus, 0);
  const remainingBonus = bookmakersList.reduce((sum, bookmaker) => {
    const state = tracker.bookmakers[bookmaker.id];
    return sum + (state && state.bonusCompleted ? 0 : bookmaker.bonus);
  }, 0);
  const completedBonus = totalBonus - remainingBonus;
  const completedCount = bookmakersList.reduce((sum, bookmaker) => {
    const state = tracker.bookmakers[bookmaker.id];
    return sum + (state && state.bonusCompleted ? 1 : 0);
  }, 0);
  const progressPercent = totalBonus > 0 ? Math.round(completedBonus / totalBonus * 100) : 0;
  const allBonusesCompleted = completedCount === bookmakersList.length && bookmakersList.length > 0;
  const confettiColors = ["#60A5FA", "#FACC15", "#F472B6", "#22C55E", "#A78BFA", "#38BDF8", "#EF4444", "#34D399"];
  const confettiPieces = Array.from({
    length: 64
  }, (_, index) => ({
    left: (index * 17 + 9) % 100 + "%",
    width: 5 + index * 7 % 8,
    height: 9 + index * 5 % 14,
    delay: index * 23 % 420,
    duration: 1100 + index * 37 % 950,
    drift: -90 + index * 29 % 180,
    sway: -30 + index * 19 % 60,
    spin: 280 + index * 47 % 520,
    rotationStart: index * 37 % 360,
    fall: 420 + index * 41 % 900,
    color: confettiColors[index % confettiColors.length],
    isRound: index % 5 === 0 || index % 9 === 0,
    opacity: 0.85 + index % 3 * 0.05
  }));
  const nudgeMessages = ["You're missing out on a free $" + totalBonus, "You could make $" + remainingBonus + " right now...", "All bonuses completed. Nice work."];
  let nudgeMessage = "";
  if (remainingBonus === totalBonus) {
    nudgeMessage = nudgeMessages[0];
  } else if (remainingBonus > 0) {
    nudgeMessage = nudgeMessages[1];
  } else {
    nudgeMessage = nudgeMessages[2];
  }
  const nudgeHeadingStyle = {
    margin: 0,
    fontSize: "28px",
    lineHeight: 1.15,
    fontWeight: 700,
    gridArea: "1 / 1"
  };
  return <div style={{
    display: "flex",
    flexDirection: "column",
    gap: "16px",
    marginTop: "20px",
    position: "relative"
  }}>
      <style>
        {`
          @keyframes tipxConfettiRain {
            0% {
              transform: translate3d(0px, -20px, 0px) rotate(var(--start-rot));
              opacity: 0;
            }
            8% {
              opacity: 1;
            }
            100% {
              transform: translate3d(var(--drift), var(--fall), 0px) rotate(var(--end-rot));
              opacity: 0;
            }
          }

          @keyframes tipxConfettiSway {
            0% { margin-left: 0px; }
            50% { margin-left: var(--sway); }
            100% { margin-left: 0px; }
          }
        `}
      </style>

      {confettiBurstId > 0 && <div key={confettiBurstId} style={{
    position: "absolute",
    inset: "0",
    pointerEvents: "none",
    overflow: "hidden",
    zIndex: 40
  }}>
          {confettiPieces.map((piece, index) => <div key={"confetti-" + confettiBurstId + "-" + index} style={{
    position: "absolute",
    top: 0,
    left: piece.left,
    width: piece.width + "px",
    height: piece.height + "px",
    borderRadius: piece.isRound ? "999px" : "2px",
    backgroundColor: piece.color,
    opacity: piece.opacity,
    "--drift": piece.drift + "px",
    "--sway": piece.sway + "px",
    "--fall": piece.fall + "px",
    "--start-rot": piece.rotationStart + "deg",
    "--end-rot": piece.rotationStart + piece.spin + "deg",
    animation: "tipxConfettiRain " + piece.duration + "ms cubic-bezier(0.2, 0.75, 0.25, 1) " + piece.delay + "ms both, tipxConfettiSway " + (420 + index % 7 * 90) + "ms ease-in-out " + piece.delay + "ms 2"
  }} />)}
        </div>}

      <div style={{
    border: "1px solid rgba(42, 54, 255, 0.35)",
    borderRadius: "14px",
    padding: "18px",
    background: "linear-gradient(150deg, rgba(42, 54, 255, 0.12), rgba(42, 54, 255, 0.03))",
    display: "flex",
    flexDirection: "column",
    gap: "12px"
  }}>
        <p style={{
    margin: 0,
    fontSize: "14px",
    opacity: 0.85
  }}>Bonus progress tracker</p>
        <div style={{
    display: "grid"
  }}>
          {nudgeMessages.map(message => <span key={"nudge-reserve-" + message} aria-hidden="true" style={{
    ...nudgeHeadingStyle,
    visibility: "hidden",
    pointerEvents: "none",
    userSelect: "none"
  }}>
              {message}
            </span>)}
          <h3 style={nudgeHeadingStyle}>{nudgeMessage}</h3>
        </div>
        <p style={{
    margin: 0,
    fontSize: "14px",
    opacity: 0.85
  }}>
          {completedCount} of {bookmakersList.length} bonuses completed ({progressPercent}%)
        </p>

        <div aria-label="Bonus completion progress" style={{
    width: "100%",
    height: "12px",
    borderRadius: "999px",
    backgroundColor: "rgba(100, 116, 139, 0.55)",
    boxShadow: "inset 0 1px 2px rgba(15, 23, 42, 0.45)",
    overflow: "hidden",
    position: "relative"
  }}>
          <div style={{
    width: progressPercent + "%",
    height: "100%",
    position: "absolute",
    top: 0,
    left: 0,
    bottom: 0,
    background: "linear-gradient(90deg, #2A36FF 0%, #16A34A 100%)",
    borderRadius: "999px",
    transition: "width 240ms ease"
  }} />
        </div>
      </div>

      <div style={{
    border: "1px solid rgba(148, 163, 184, 0.35)",
    borderRadius: "12px",
    padding: "14px",
    backgroundColor: "rgba(148, 163, 184, 0.08)"
  }}>
        <p style={{
    margin: 0,
    fontSize: "14px"
  }}>
          Use the Discord <strong>#🤖・bonus-converter</strong> channel to complete each bonus.
        </p>
      </div>

      <div style={{
    display: "grid",
    gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
    gap: "12px",
    alignItems: "start"
  }}>
        {bookmakersList.map(bookmaker => {
    const state = tracker.bookmakers[bookmaker.id] || ({
      signedUp: false,
      bonusCompleted: false
    });
    const isCompleted = state.bonusCompleted;
    const signedUpButtonStyle = {
      width: "100%",
      borderRadius: "10px",
      border: state.signedUp ? "1px solid rgba(37, 99, 235, 0.55)" : "1px solid rgba(148, 163, 184, 0.45)",
      background: state.signedUp ? "linear-gradient(180deg, rgba(37, 99, 235, 0.28), rgba(37, 99, 235, 0.16))" : "rgba(15, 23, 42, 0.18)",
      color: state.signedUp ? "#dbeafe" : "inherit",
      padding: "10px 12px",
      fontSize: "13px",
      fontWeight: 700,
      cursor: "pointer",
      textAlign: "left"
    };
    const completedButtonStyle = {
      width: "100%",
      borderRadius: "10px",
      border: state.bonusCompleted ? "1px solid rgba(22, 163, 74, 0.6)" : "1px solid rgba(148, 163, 184, 0.45)",
      background: state.bonusCompleted ? "linear-gradient(180deg, rgba(22, 163, 74, 0.32), rgba(22, 163, 74, 0.18))" : "rgba(15, 23, 42, 0.18)",
      color: state.bonusCompleted ? "#dcfce7" : "inherit",
      padding: "10px 12px",
      fontSize: "13px",
      fontWeight: 700,
      cursor: "pointer",
      textAlign: "left"
    };
    return <div key={bookmaker.id} style={{
      border: isCompleted ? "1px solid rgba(22, 163, 74, 0.55)" : "1px solid rgba(148, 163, 184, 0.32)",
      borderRadius: "12px",
      padding: "14px",
      display: "flex",
      flexDirection: "column",
      gap: "12px",
      alignSelf: "start",
      width: "100%",
      background: isCompleted ? "linear-gradient(160deg, rgba(22, 163, 74, 0.2), rgba(22, 163, 74, 0.07))" : "rgba(15, 23, 42, 0.04)",
      boxShadow: isCompleted ? "0 8px 26px rgba(22, 163, 74, 0.2)" : "none",
      position: "relative",
      overflow: "hidden"
    }}>
              <div style={{
      display: "flex",
      justifyContent: "space-between",
      alignItems: "center",
      gap: "8px"
    }}>
                <div style={{
      display: "flex",
      alignItems: "center",
      gap: "8px",
      minWidth: 0
    }}>
                  <div style={{
      width: "32px",
      height: "32px",
      borderRadius: "8px",
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      backgroundColor: "rgba(15, 23, 42, 0.28)",
      flexShrink: 0
    }}>
                    <img src={bookmaker.logo} alt={bookmaker.name + " logo"} width="28" height="28" draggable={false} style={{
      width: "28px",
      height: "28px",
      borderRadius: "6px",
      objectFit: "cover",
      display: "block",
      pointerEvents: "none"
    }} />
                  </div>
                  <strong style={{
      fontSize: "16px",
      lineHeight: 1.1,
      whiteSpace: "nowrap"
    }}>{bookmaker.name}</strong>
                </div>
                <span style={{
      fontSize: "13px",
      fontWeight: 600,
      color: "#16A34A",
      backgroundColor: "rgba(22, 163, 74, 0.12)",
      borderRadius: "999px",
      padding: "3px 8px",
      whiteSpace: "nowrap"
    }}>
                  +${bookmaker.bonus}
                </span>
              </div>

              <div style={{
      display: "flex",
      flexDirection: "column",
      gap: "8px",
      width: "100%"
    }}>
                <button type="button" aria-pressed={state.signedUp} onClick={() => handleSignedUpToggle(bookmaker.id)} style={signedUpButtonStyle}>
                  {state.signedUp ? "Signed up" : "Mark as signed up"}
                </button>
                <button type="button" aria-pressed={state.bonusCompleted} onClick={() => handleBonusCompletedToggle(bookmaker.id)} style={completedButtonStyle}>
                  {state.bonusCompleted ? "Bonus completed" : "Mark bonus completed"}
                </button>
              </div>
            </div>;
  })}
      </div>

      <div style={{
    padding: "0 0 4px",
    display: "flex",
    flexDirection: "column",
    gap: "8px",
    alignItems: "center"
  }}>
        {allBonusesCompleted ? <>
            <a href={phaseOneDiscordDeepLink} style={{
    width: "100%",
    maxWidth: "420px",
    textAlign: "center",
    borderRadius: "10px",
    border: "1px solid rgba(88, 101, 242, 0.65)",
    background: "linear-gradient(180deg, rgba(88, 101, 242, 0.95), rgba(88, 101, 242, 0.82))",
    color: "#ffffff",
    padding: "12px 14px",
    fontSize: "14px",
    fontWeight: 700,
    textDecoration: "none"
  }}>
              Open #📘・phase-one in Discord
            </a>
          </> : <button type="button" disabled style={{
    width: "100%",
    maxWidth: "420px",
    borderRadius: "10px",
    border: "1px solid rgba(148, 163, 184, 0.45)",
    background: "rgba(15, 23, 42, 0.2)",
    color: "rgba(255, 255, 255, 0.7)",
    padding: "12px 14px",
    fontSize: "14px",
    fontWeight: 700,
    cursor: "not-allowed",
    opacity: 0.85
  }}>
            Complete all bonuses to unlock #📘・phase-one
          </button>}
        {allBonusesCompleted ? <p style={{
    margin: 0,
    fontSize: "13px",
    opacity: 0.85,
    textAlign: "center"
  }}>
            Click{" "}
            <a href={phaseOneDiscordFallbackUrl} style={{
    color: "inherit",
    textDecoration: "underline"
  }}>
              here
            </a>{" "}
            if "Open Discord" is not working...
          </p> : <p style={{
    margin: 0,
    fontSize: "13px",
    opacity: 0.85
  }}>
            {"Progress: " + completedCount + "/" + bookmakersList.length + " bonuses completed."}
          </p>}
      </div>

      {storageNotice && <p style={{
    margin: 0,
    fontSize: "12px",
    color: "#DC2626"
  }}>
          {storageNotice}
        </p>}
    </div>;
};

<BonusTrackerPanel />
