feat(vault): wire Guadagni window to earnings packets

Request earnings on open (RequestEarningsCenterComposer), render real
amounts/claimable per category from EarningsCenterEvent, per-row Riscatta +
Richiedili Tutti send the claim composers, refresh on EarningsClaimResultEvent.
Category keys aligned to the emulator contract; reward currencies derived from
reward type; rows fall back to the static skeleton before data lands.
This commit is contained in:
simoleo89
2026-06-15 22:00:08 +02:00
parent e94463df6a
commit d15457b43c
+153 -41
View File
@@ -1,6 +1,6 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText } from '../../api';
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';
@@ -12,6 +12,7 @@ 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) =>
{
@@ -19,37 +20,56 @@ const localizeWithFallback = (key: string, fallback: string) =>
return (text && text !== key) ? text : fallback;
};
interface EarningRow
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;
currencies: number[];
// Placeholder currency icons used only before the server entry arrives.
fallbackCurrencies: number[];
}
// Icons are the hotel's real earnings_icon_* assets. Amounts are placeholders
// (0) and claims are disabled until the emulator exposes the data + packets.
// 'games' and 'clubwork' have no standard earnings.*.label key — they use a
// custom key (add it to your texts) and fall back to the Italian label.
const EARNINGS: EarningRow[] = [
{ key: 'daily', textKey: 'earnings.dailygift.label', label: 'Regalo giornaliero', img: imgDailygift, currencies: [ 5 ] },
{ key: 'games', textKey: 'earnings.games.label', label: 'Giochi', img: imgGames, currencies: [ 0 ] },
{ key: 'achievements', textKey: 'earnings.achievements.label', label: 'Traguardi', img: imgAchievements, currencies: [ 5, 0 ] },
{ key: 'marketplace', textKey: 'earnings.marketplace.label', label: 'Mercatino', img: imgMarketplace, currencies: [ 0 ] },
{ key: 'hcpayday', textKey: 'earnings.hc.label', label: 'Bonus giorno di paga HC', img: imgHcpayday, currencies: [ 0 ] },
{ key: 'level', textKey: 'earnings.levelprogression.label', label: 'Progressione Livello', img: imgLevel, currencies: [ 5, 0 ] },
{ key: 'donations', textKey: 'earnings.donations.label', label: 'Donazioni', img: imgDonations, currencies: [ 0 ] },
{ key: 'bonusbag', textKey: 'earnings.bonusbag.label', label: 'Sacco Bonus', img: imgBonusbag, currencies: [ 0 ] },
{ key: 'surprise', textKey: 'earnings.surpriseboxes.label', label: 'Scatole Sorprese', img: imgSurprise, currencies: [ 5, 0 ] },
{ key: 'clubwork', textKey: 'earnings.clubwork.label', label: 'Club e Lavoro', img: imgGeneric, currencies: [ 0 ] }
// 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.
// 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%);
@@ -65,6 +85,51 @@ const VAULT_STYLES = `
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(() =>
{
@@ -97,6 +162,23 @@ export const VaultView: FC<{}> = props =>
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 (
@@ -104,29 +186,59 @@ export const VaultView: FC<{}> = props =>
<NitroCardHeaderView headerText={ localizeWithFallback('earnings.title', 'Guadagni') } onCloseClick={ () => setIsVisible(false) } />
<NitroCardContentView className="flex flex-col gap-[3px] text-black">
<style>{ VAULT_STYLES }</style>
{ EARNINGS.map(row => (
<div key={ row.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={ row.img } alt="" className="max-h-[20px] max-w-[20px] object-contain [image-rendering:pixelated]" />
</span>
<Text bold className="truncate">{ localizeWithFallback(row.textKey, row.label) }</Text>
</div>
<div className="flex min-w-[92px] shrink-0 items-center justify-end gap-2.5">
{ row.currencies.map((currency, index) => (
<span key={ index } className="flex items-center gap-1">
<LayoutCurrencyIcon type={ currency } />
<Text bold>0</Text>
{ 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>
<button type="button" disabled className="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 className="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]">
<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>