Merge pull request #249 from simoleo89/feat/earnings-center-dev

feat(vault): functional Guadagni (Earnings Center) window
This commit is contained in:
DuckieTM
2026-06-17 10:05:03 +02:00
committed by GitHub
12 changed files with 250 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 724 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 815 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 524 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 776 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 780 B

+2
View File
@@ -45,6 +45,7 @@ import { TranslationSettingsView } from './translation/TranslationSettingsView';
import { UserProfileView } from './user-profile/UserProfileView';
import { UserAccountSettingsView } from './user-settings/UserAccountSettingsView';
import { UserSettingsView } from './user-settings/UserSettingsView';
import { VaultView } from './vault/VaultView';
import { WiredView } from './wired/WiredView';
import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
import { MentionsView } from './mentions';
@@ -221,6 +222,7 @@ export const MainView: FC<{}> = props =>
<RightSideView />
<UserSettingsView />
<UserAccountSettingsView />
<VaultView />
<TranslationSettingsView />
<UserProfileView />
<GroupsView />
+248
View File
@@ -0,0 +1,248 @@
import { AddLinkEventTracker, ClaimAllEarningsRewardsComposer, ClaimEarningsRewardComposer, EarningsCenterEvent, EarningsClaimResultEvent, IEarningsEntry, IEarningsReward, ILinkEventTracker, RemoveLinkEventTracker, RequestEarningsCenterComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../api';
import imgAchievements from '../../assets/images/vault/achievements.png';
import imgBonusbag from '../../assets/images/vault/bonusbag.png';
import imgDailygift from '../../assets/images/vault/dailygift.png';
import imgDonations from '../../assets/images/vault/donations.png';
import imgGames from '../../assets/images/vault/games.png';
import imgGeneric from '../../assets/images/vault/generic.png';
import imgHcpayday from '../../assets/images/vault/hcpayday.png';
import imgLevel from '../../assets/images/vault/levelprogression.png';
import imgMarketplace from '../../assets/images/vault/marketplace.png';
import imgSurprise from '../../assets/images/vault/surprise.png';
import { LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useMessageEvent } from '../../hooks';
const localizeWithFallback = (key: string, fallback: string) =>
{
const text = LocalizeText(key);
return (text && text !== key) ? text : fallback;
};
interface EarningCategory
{
// Wire categoryKey — MUST match the emulator contract
// (emulatore/docs/earnings-packet-contract.md).
key: string;
// Standard gamedata localization key (ExternalTexts). 'label' is only the
// fallback shown when the key is missing in the active texts.
textKey: string;
label: string;
img: string;
// Placeholder currency icons used only before the server entry arrives.
fallbackCurrencies: number[];
}
// Fixed display order + icons/labels. Amounts, claimable state and the actual
// reward currencies come from the server (EarningsCenterEvent); these rows are
// the always-visible skeleton so the window matches the Habbo reference even
// before data lands. 'games' and 'club_job' have no standard earnings.*.label
// key — they use a custom key (add it to your texts) and fall back to Italian.
const CATEGORIES: EarningCategory[] = [
{ key: 'daily_gift', textKey: 'earnings.dailygift.label', label: 'Regalo giornaliero', img: imgDailygift, fallbackCurrencies: [ 5 ] },
{ key: 'games', textKey: 'earnings.games.label', label: 'Giochi', img: imgGames, fallbackCurrencies: [ 0 ] },
{ key: 'achievements', textKey: 'earnings.achievements.label', label: 'Traguardi', img: imgAchievements, fallbackCurrencies: [ 5, 0 ] },
{ key: 'marketplace', textKey: 'earnings.marketplace.label', label: 'Mercatino', img: imgMarketplace, fallbackCurrencies: [ 0 ] },
{ key: 'hc_payday', textKey: 'earnings.hc.label', label: 'Bonus giorno di paga HC', img: imgHcpayday, fallbackCurrencies: [ 0 ] },
{ key: 'level_progress', textKey: 'earnings.levelprogression.label', label: 'Progressione Livello', img: imgLevel, fallbackCurrencies: [ 5, 0 ] },
{ key: 'donations', textKey: 'earnings.donations.label', label: 'Donazioni', img: imgDonations, fallbackCurrencies: [ 0 ] },
{ key: 'bonus_bag', textKey: 'earnings.bonusbag.label', label: 'Sacco Bonus', img: imgBonusbag, fallbackCurrencies: [ 0 ] },
{ key: 'mystery_boxes', textKey: 'earnings.surpriseboxes.label', label: 'Scatole Sorprese', img: imgSurprise, fallbackCurrencies: [ 5, 0 ] },
{ key: 'club_job', textKey: 'earnings.clubwork.label', label: 'Club e Lavoro', img: imgGeneric, fallbackCurrencies: [ 0 ] }
];
// Map a server reward type to a LayoutCurrencyIcon `type`. Returns null for
// rewards that aren't a currency (badge / item) — those show just the amount.
const rewardCurrencyType = (reward: IEarningsReward): number | string | null =>
{
switch(reward.type)
{
case 'credits': return -1;
case 'pixels': return 0;
case 'points': return reward.pointsType;
case 'hc_days': return 'hc';
default: return null;
}
};
// Scoped colour override for the Guadagni window only: classic blue header +
// cool grey body (the shared 'primary-slim' theme is teal + cream). Higher
// specificity (.nitro-card.nitro-vault ...) than the theme so it wins. The body
// element renders `.nitro-card-content-shell`, NOT `.content-area`.
const VAULT_STYLES = `
.nitro-card.nitro-vault .nitro-card-header {
background: linear-gradient(180deg, #5a80b8 0%, #3f63a0 100%);
border-color: #34548a;
}
.nitro-card.nitro-vault,
.nitro-card.nitro-vault .content-area,
.nitro-card.nitro-vault .nitro-card-content-shell {
background: #dde1e6 !important;
}
`;
export const VaultView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ entries, setEntries ] = useState<IEarningsEntry[]>([]);
const entriesByKey = useMemo(() =>
{
const map = new Map<string, IEarningsEntry>();
for(const entry of entries) map.set(entry.categoryKey, entry);
return map;
}, [ entries ]);
const anyClaimable = useMemo(() => entries.some(entry => entry.enabled && entry.claimable), [ entries ]);
useMessageEvent<EarningsCenterEvent>(EarningsCenterEvent, useCallback((event: EarningsCenterEvent) =>
{
const parser = event.getParser();
if(!parser) return;
setEntries(parser.entries ?? []);
}, []));
useMessageEvent<EarningsClaimResultEvent>(EarningsClaimResultEvent, useCallback((event: EarningsClaimResultEvent) =>
{
const parser = event.getParser();
if(!parser) return;
setEntries(prev =>
{
const next = prev.slice();
for(const result of parser.results)
{
if(result.hasEntry && result.entry)
{
const idx = next.findIndex(e => e.categoryKey === result.entry.categoryKey);
if(idx >= 0) next[idx] = result.entry; else next.push(result.entry);
}
else if(result.success)
{
// No refreshed entry but the claim worked — mark it spent.
const idx = next.findIndex(e => e.categoryKey === result.categoryKey);
if(idx >= 0) next[idx] = { ...next[idx], claimable: false };
}
}
return next;
});
}, []));
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 3) return;
if(parts[2] !== 'vault') return;
switch(parts[1])
{
case 'open':
setIsVisible(true);
return;
case 'close':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
return;
}
},
eventUrlPrefix: 'habboUI/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
// Ask the server for fresh earnings every time the window opens.
useEffect(() =>
{
if(!isVisible) return;
SendMessageComposer(new RequestEarningsCenterComposer());
}, [ isVisible ]);
const claimOne = useCallback((categoryKey: string) =>
{
SendMessageComposer(new ClaimEarningsRewardComposer(categoryKey));
}, []);
const claimAll = useCallback(() =>
{
SendMessageComposer(new ClaimAllEarningsRewardsComposer());
}, []);
if(!isVisible) return null;
return (
<NitroCardView className="nitro-vault w-[430px]" theme="primary-slim" uniqueKey="vault">
<NitroCardHeaderView headerText={ localizeWithFallback('earnings.title', 'Guadagni') } onCloseClick={ () => setIsVisible(false) } />
<NitroCardContentView className="flex flex-col gap-[3px] text-black">
<style>{ VAULT_STYLES }</style>
{ CATEGORIES.map(category =>
{
const entry = entriesByKey.get(category.key) ?? null;
const canClaim = !!entry && entry.enabled && entry.claimable;
const rewards = entry?.rewards ?? [];
return (
<div key={ category.key } className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-[5px] border border-[#9aa0a8] bg-white px-1.5 py-1">
<span className="flex h-[24px] w-[24px] shrink-0 items-center justify-center rounded border border-black/15 bg-white">
<img src={ category.img } alt="" className="max-h-[20px] max-w-[20px] object-contain [image-rendering:pixelated]" />
</span>
<Text bold className="truncate">{ localizeWithFallback(category.textKey, category.label) }</Text>
</div>
<div className="flex min-w-[92px] shrink-0 items-center justify-end gap-2.5">
{ rewards.length > 0
? rewards.map((reward, index) =>
{
const currencyType = rewardCurrencyType(reward);
return (
<span key={ index } className="flex items-center gap-1">
{ currencyType !== null && <LayoutCurrencyIcon type={ currencyType } /> }
<Text bold>{ reward.amount }</Text>
</span>
);
})
: category.fallbackCurrencies.map((currency, index) => (
<span key={ index } className="flex items-center gap-1">
<LayoutCurrencyIcon type={ currency } />
<Text bold>0</Text>
</span>
)) }
</div>
<button
type="button"
disabled={ !canClaim }
onClick={ canClaim ? () => claimOne(category.key) : undefined }
className={ canClaim
? 'shrink-0 cursor-pointer rounded-[4px] border border-[#4f7a22] bg-[linear-gradient(180deg,#72b03a_0%,#5a8c2a_100%)] px-2.5 py-[3px] text-[0.72rem] font-bold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.3)] hover:brightness-105'
: 'shrink-0 cursor-default rounded-[4px] border border-[#909090] bg-[linear-gradient(180deg,#f2f2f2_0%,#cdcdcd_100%)] px-2.5 py-[3px] text-[0.72rem] font-bold text-[#7c7c7c] shadow-[inset_0_1px_0_#ffffff]' }>
{ localizeWithFallback('earnings.claim.button', 'Riscatta') }
</button>
</div>
);
}) }
<div className="flex justify-center pt-1">
<button
type="button"
disabled={ !anyClaimable }
onClick={ anyClaimable ? claimAll : undefined }
className={ anyClaimable
? 'cursor-pointer rounded-[4px] border border-[#4f7a22] bg-[linear-gradient(180deg,#72b03a_0%,#5a8c2a_100%)] px-7 py-1 text-[0.78rem] font-bold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.3)] hover:brightness-105'
: 'cursor-default rounded-[4px] border border-[#909090] bg-[linear-gradient(180deg,#f2f2f2_0%,#cdcdcd_100%)] px-7 py-1 text-[0.78rem] font-bold text-[#7c7c7c] shadow-[inset_0_1px_0_#ffffff]' }>
{ localizeWithFallback('earnings.claim.all', 'Richiedili Tutti') }
</button>
</div>
</NitroCardContentView>
</NitroCardView>
);
};