generated from nhcarrigan/template
feat: add daily login bonus system with streak tracking
Awards escalating gold rewards each consecutive day (Day 7 grants bonus crystals). Streak resets on missed days; a week multiplier boosts all rewards for longer streaks. Shows a modal on first daily load and displays the current streak on the character sheet.
This commit is contained in:
@@ -79,6 +79,10 @@ const HOW_TO_PLAY = [
|
||||
title: "π Leaderboards",
|
||||
body: "Compete with other adventurers on the public Leaderboards page! Categories include Lifetime Gold, Bosses Defeated, Quests Completed, Achievements, Prestige Count, Transcendence Count, and Apotheosis Count. Click any player's row to view their character sheet. You can opt out of appearing on leaderboards via the Privacy section in your profile settings.",
|
||||
},
|
||||
{
|
||||
title: "π₯ Daily Login Bonus",
|
||||
body: "Log in every day to earn escalating rewards! Each consecutive day awards more gold, and the 7th day of your streak grants bonus crystals. Your streak resets if you miss a day. A week multiplier increases all rewards the longer your overall streak runs. Your current streak is displayed on your character sheet.",
|
||||
},
|
||||
{
|
||||
title: "βοΈ Cloud Saves",
|
||||
body: "Your progress is automatically saved to the cloud every 30 seconds whilst you play. You can also force a manual save at any time using the sync button in the resource bar. Your save is protected by HMAC validation to ensure data integrity.",
|
||||
|
||||
@@ -58,7 +58,7 @@ const formatBonus = (bonus: EquipmentBonus): string => {
|
||||
};
|
||||
|
||||
export const CharacterSheetPanel = (): React.JSX.Element => {
|
||||
const { state } = useGame();
|
||||
const { state, loginStreak } = useGame();
|
||||
const player = state?.player;
|
||||
|
||||
const [sheet, setSheet] = useState<CharacterSheetData>(EMPTY_SHEET);
|
||||
@@ -328,6 +328,12 @@ export const CharacterSheetPanel = (): React.JSX.Element => {
|
||||
{sheet.characterName || <em className="character-sheet-empty">Not set</em>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Streak</span>
|
||||
<span className="character-sheet-streak">
|
||||
π₯ {loginStreak}-day login streak
|
||||
</span>
|
||||
</div>
|
||||
{sheet.activeTitle && (
|
||||
<div className="character-sheet-field">
|
||||
<span className="character-sheet-field-label">Title</span>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { DailyChallengePanel } from "./DailyChallengePanel.js";
|
||||
import { ExplorationPanel } from "./ExplorationPanel.js";
|
||||
import { CharacterSheetPanel } from "./CharacterSheetPanel.js";
|
||||
import { CraftingPanel } from "./CraftingPanel.js";
|
||||
import { LoginBonusModal } from "./LoginBonusModal.js";
|
||||
|
||||
type Tab = "adventurers" | "upgrades" | "quests" | "bosses" | "equipment" | "achievements" | "prestige" | "transcendence" | "apotheosis" | "statistics" | "daily" | "codex" | "about" | "exploration" | "crafting" | "character";
|
||||
|
||||
@@ -46,7 +47,7 @@ const BASE_TABS: { id: Tab; label: string }[] = [
|
||||
];
|
||||
|
||||
export const GameLayout = (): React.JSX.Element => {
|
||||
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds } = useGame();
|
||||
const { state, isLoading, error, battleResult, dismissBattle, lastSavedAt, isSyncing, forceSync, newCodexEntryIds, loginBonus, dismissLoginBonus } = useGame();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("adventurers");
|
||||
const [editingProfile, setEditingProfile] = useState(false);
|
||||
|
||||
@@ -87,6 +88,9 @@ export const GameLayout = (): React.JSX.Element => {
|
||||
<OfflineModal />
|
||||
<AchievementToast />
|
||||
<CodexToast />
|
||||
{loginBonus && (
|
||||
<LoginBonusModal bonus={loginBonus} onClose={dismissLoginBonus} />
|
||||
)}
|
||||
{battleResult && (
|
||||
<BattleModal battle={battleResult} onDismiss={dismissBattle} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { LoginBonusResult } from "@elysium/types";
|
||||
|
||||
interface LoginBonusModalProps {
|
||||
bonus: LoginBonusResult;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DAY_ICONS = ["π±", "πΏ", "βοΈ", "π‘οΈ", "π", "π", "π₯"];
|
||||
|
||||
const formatGold = (value: number): string => {
|
||||
const suffixes = ["", "K", "M", "B", "T"];
|
||||
if (value < 1_000) return value.toLocaleString();
|
||||
const tier = Math.min(Math.floor(Math.log10(value) / 3), suffixes.length - 1);
|
||||
const scaled = value / Math.pow(1_000, tier);
|
||||
return `${parseFloat(scaled.toFixed(1))}${suffixes[tier] ?? ""}`;
|
||||
};
|
||||
|
||||
export const LoginBonusModal = ({ bonus, onClose }: LoginBonusModalProps): React.JSX.Element => {
|
||||
const isWeeklyBonus = bonus.day === 7;
|
||||
const dayIcon = DAY_ICONS[bonus.day - 1] ?? "β";
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div className="modal login-bonus-modal">
|
||||
<div className="login-bonus-streak">
|
||||
<span className="login-bonus-fire">π₯</span>
|
||||
<span className="login-bonus-streak-count">{bonus.streak}</span>
|
||||
<span className="login-bonus-streak-label">
|
||||
{bonus.streak === 1 ? "Day Streak" : "Day Streak"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="login-bonus-day-badge">
|
||||
<span className="login-bonus-day-icon">{dayIcon}</span>
|
||||
<span className="login-bonus-day-label">Day {bonus.day} Reward</span>
|
||||
{bonus.weekMultiplier > 1 && (
|
||||
<span className="login-bonus-week-tag">Γ{bonus.weekMultiplier} Week Bonus!</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="login-bonus-rewards">
|
||||
<div className="login-bonus-reward-item">
|
||||
<span className="login-bonus-reward-icon">πͺ</span>
|
||||
<span className="login-bonus-reward-value">+{formatGold(bonus.goldEarned)} Gold</span>
|
||||
</div>
|
||||
{bonus.crystalsEarned > 0 && (
|
||||
<div className="login-bonus-reward-item">
|
||||
<span className="login-bonus-reward-icon">π</span>
|
||||
<span className="login-bonus-reward-value">+{bonus.crystalsEarned} Crystals</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isWeeklyBonus && (
|
||||
<p className="login-bonus-weekly-message">
|
||||
π Weekly bonus β keep the streak going!
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="login-bonus-calendar">
|
||||
{DAY_ICONS.map((icon, i) => {
|
||||
const dayNum = i + 1;
|
||||
const isCompleted = dayNum < bonus.day || (bonus.day === 7 && dayNum === 7);
|
||||
const isToday = dayNum === bonus.day;
|
||||
return (
|
||||
<div
|
||||
key={dayNum}
|
||||
className={`login-bonus-cal-day ${isToday ? "login-bonus-cal-day--today" : ""} ${isCompleted ? "login-bonus-cal-day--done" : ""}`}
|
||||
>
|
||||
<span className="login-bonus-cal-icon">{icon}</span>
|
||||
<span className="login-bonus-cal-num">{dayNum}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button className="login-bonus-claim-btn" onClick={onClose} type="button">
|
||||
Claim Reward
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Achievement, BossChallengeResponse, ExploreCollectResponse, GameState, NumberFormat } from "@elysium/types";
|
||||
import type { Achievement, BossChallengeResponse, ExploreCollectResponse, GameState, LoginBonusResult, NumberFormat } from "@elysium/types";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -101,6 +101,12 @@ interface GameContextValue {
|
||||
collectExploration: (areaId: string) => Promise<ExploreCollectResponse>;
|
||||
/** Craft a recipe using collected materials */
|
||||
craftRecipe: (recipeId: string) => Promise<void>;
|
||||
/** Daily login bonus earned on this session load (null if already claimed today) */
|
||||
loginBonus: LoginBonusResult | null;
|
||||
/** Player's current login streak (days) */
|
||||
loginStreak: number;
|
||||
/** Dismiss the login bonus modal */
|
||||
dismissLoginBonus: () => void;
|
||||
}
|
||||
|
||||
const GameContext = createContext<GameContextValue | null>(null);
|
||||
@@ -115,6 +121,8 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [offlineGold, setOfflineGold] = useState(0);
|
||||
const [offlineEssence, setOfflineEssence] = useState(0);
|
||||
const [loginBonus, setLoginBonus] = useState<LoginBonusResult | null>(null);
|
||||
const [loginStreak, setLoginStreak] = useState(1);
|
||||
const [battleResult, setBattleResult] = useState<BattleResult | null>(null);
|
||||
const [newAchievements, setNewAchievements] = useState<Achievement[]>([]);
|
||||
const [lastSavedAt, setLastSavedAt] = useState<number | null>(null);
|
||||
@@ -152,6 +160,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
if (data.offlineEssence > 0) {
|
||||
setOfflineEssence(data.offlineEssence);
|
||||
}
|
||||
if (data.loginBonus) {
|
||||
setLoginBonus(data.loginBonus);
|
||||
}
|
||||
setLoginStreak(data.loginStreak ?? 1);
|
||||
// Fetch number format preference from profile (fire-and-forget, non-blocking)
|
||||
void fetch(`/api/profile/${data.state.player.discordId}`)
|
||||
.then(async (res) => {
|
||||
@@ -863,6 +875,10 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
setNewCodexEntryIds((prev) => prev.filter((e) => e !== id));
|
||||
}, []);
|
||||
|
||||
const dismissLoginBonus = useCallback(() => {
|
||||
setLoginBonus(null);
|
||||
}, []);
|
||||
|
||||
const boundFormatNumber = useCallback(
|
||||
(value: number) => formatNumberUtil(value, numberFormat),
|
||||
[numberFormat],
|
||||
@@ -906,6 +922,9 @@ export const GameProvider = ({ children }: { children: React.ReactNode }): React
|
||||
startExploration,
|
||||
collectExploration,
|
||||
craftRecipe,
|
||||
loginBonus,
|
||||
loginStreak,
|
||||
dismissLoginBonus,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -3765,3 +3765,167 @@ body {
|
||||
font-size: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ββ Login Bonus Modal ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
||||
|
||||
.login-bonus-modal {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 420px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-bonus-streak {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.login-bonus-fire {
|
||||
font-size: 2.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-bonus-streak-count {
|
||||
color: var(--colour-accent);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-bonus-streak-label {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.login-bonus-day-badge {
|
||||
align-items: center;
|
||||
background: var(--colour-surface);
|
||||
border: 2px solid var(--colour-accent);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-bonus-day-icon {
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-bonus-day-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-bonus-week-tag {
|
||||
background: #d4af37;
|
||||
border-radius: 999px;
|
||||
color: #000;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.15rem 0.75rem;
|
||||
}
|
||||
|
||||
.login-bonus-rewards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-bonus-reward-item {
|
||||
align-items: center;
|
||||
background: var(--colour-surface);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.login-bonus-reward-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.login-bonus-reward-value {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-bonus-weekly-message {
|
||||
color: #d4af37;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-bonus-calendar {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-bonus-cal-day {
|
||||
align-items: center;
|
||||
background: var(--colour-surface);
|
||||
border: 1px solid var(--colour-border);
|
||||
border-radius: var(--radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.4rem 0.2rem;
|
||||
}
|
||||
|
||||
.login-bonus-cal-day--done {
|
||||
border-color: var(--colour-success, #4caf50);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.login-bonus-cal-day--today {
|
||||
border-color: var(--colour-accent);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.login-bonus-cal-icon {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-bonus-cal-num {
|
||||
color: var(--colour-text-muted);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.login-bonus-claim-btn {
|
||||
background: var(--colour-accent);
|
||||
border-radius: var(--radius);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
padding: 0.7rem 2.5rem;
|
||||
transition: opacity 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-bonus-claim-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ββ Character Sheet Streak βββββββββββββββββββββββββββββββββββββββββββ */
|
||||
|
||||
.character-sheet-streak {
|
||||
color: var(--colour-accent);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user