mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 15:36:18 +00:00
🆕 Redesign of HC Club buy, now also give as gift
This commit is contained in:
@@ -265,4 +265,5 @@
|
|||||||
"loading.task.userdata": "loading user data...",
|
"loading.task.userdata": "loading user data...",
|
||||||
"loading.task.rooms": "loading rooms...",
|
"loading.task.rooms": "loading rooms...",
|
||||||
"loading.task.engine": "loading graphics engine...",
|
"loading.task.engine": "loading graphics engine...",
|
||||||
|
"catalog.gift_wrapping.gift_sent": "Done!"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ClubOfferData, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
|
import { ClubOfferData, GiftReceiverNotFoundEvent, PurchaseFromCatalogAsGiftComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api';
|
||||||
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
|
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutLoadingSpinnerView, Text } from '../../../../../common';
|
||||||
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
|
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
|
||||||
import { useCatalogData, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks';
|
import { useCatalogData, useClubOffers, useMessageEvent, usePurse, useUiEvent, useUserDataSnapshot } from '../../../../../hooks';
|
||||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||||
|
|
||||||
const VIP_WINDOW_ID = 1;
|
const VIP_WINDOW_ID = 1;
|
||||||
@@ -12,11 +12,19 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
|||||||
{
|
{
|
||||||
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
|
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
|
||||||
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
|
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
|
||||||
|
const [ giftMode, setGiftMode ] = useState(false);
|
||||||
|
const [ giftRecipient, setGiftRecipient ] = useState('');
|
||||||
|
const [ giftError, setGiftError ] = useState<string | null>(null);
|
||||||
|
const [ giftSuccess, setGiftSuccess ] = useState(false);
|
||||||
const { currentPage = null } = useCatalogData();
|
const { currentPage = null } = useCatalogData();
|
||||||
const { purse = null, getCurrencyAmount = null } = usePurse();
|
const { purse = null, getCurrencyAmount = null } = usePurse();
|
||||||
const { data: offers = null } = useClubOffers(VIP_WINDOW_ID);
|
const { data: offers = null } = useClubOffers(VIP_WINDOW_ID);
|
||||||
|
const { userName: ownUserName = '' } = useUserDataSnapshot();
|
||||||
const isPurchasingRef = useRef<boolean>(false);
|
const isPurchasingRef = useRef<boolean>(false);
|
||||||
|
const wasGiftPurchaseRef = useRef<boolean>(false);
|
||||||
|
const giftSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const isSelfGift = giftMode && !!ownUserName && giftRecipient.trim().toLowerCase() === ownUserName.toLowerCase();
|
||||||
const onCatalogEvent = useCallback((event: CatalogEvent) =>
|
const onCatalogEvent = useCallback((event: CatalogEvent) =>
|
||||||
{
|
{
|
||||||
switch(event.type)
|
switch(event.type)
|
||||||
@@ -24,9 +32,20 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
|||||||
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
|
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
|
||||||
isPurchasingRef.current = false;
|
isPurchasingRef.current = false;
|
||||||
setPurchaseState(CatalogPurchaseState.NONE);
|
setPurchaseState(CatalogPurchaseState.NONE);
|
||||||
|
setGiftError(null);
|
||||||
|
if(wasGiftPurchaseRef.current)
|
||||||
|
{
|
||||||
|
wasGiftPurchaseRef.current = false;
|
||||||
|
setGiftRecipient('');
|
||||||
|
setGiftMode(false);
|
||||||
|
setGiftSuccess(true);
|
||||||
|
if(giftSuccessTimerRef.current) clearTimeout(giftSuccessTimerRef.current);
|
||||||
|
giftSuccessTimerRef.current = setTimeout(() => setGiftSuccess(false), 3500);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
|
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
|
||||||
isPurchasingRef.current = false;
|
isPurchasingRef.current = false;
|
||||||
|
wasGiftPurchaseRef.current = false;
|
||||||
setPurchaseState(CatalogPurchaseState.FAILED);
|
setPurchaseState(CatalogPurchaseState.FAILED);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -35,6 +54,21 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
|||||||
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, onCatalogEvent);
|
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, onCatalogEvent);
|
||||||
useUiEvent(CatalogPurchaseFailureEvent.PURCHASE_FAILED, onCatalogEvent);
|
useUiEvent(CatalogPurchaseFailureEvent.PURCHASE_FAILED, onCatalogEvent);
|
||||||
|
|
||||||
|
useEffect(() => () =>
|
||||||
|
{
|
||||||
|
if(giftSuccessTimerRef.current) clearTimeout(giftSuccessTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleGiftReceiverNotFound = useCallback(() =>
|
||||||
|
{
|
||||||
|
if(!isPurchasingRef.current) return;
|
||||||
|
isPurchasingRef.current = false;
|
||||||
|
setPurchaseState(CatalogPurchaseState.NONE);
|
||||||
|
setGiftError(LocalizeText('catalog.gift_wrapping.receiver_not_found.title'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useMessageEvent<GiftReceiverNotFoundEvent>(GiftReceiverNotFoundEvent, handleGiftReceiverNotFound);
|
||||||
|
|
||||||
const getOfferText = useCallback((offer: ClubOfferData) =>
|
const getOfferText = useCallback((offer: ClubOfferData) =>
|
||||||
{
|
{
|
||||||
let offerText = '';
|
let offerText = '';
|
||||||
@@ -89,16 +123,40 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
|||||||
const purchaseSubscription = useCallback(() =>
|
const purchaseSubscription = useCallback(() =>
|
||||||
{
|
{
|
||||||
if(!pendingOffer || isPurchasingRef.current) return;
|
if(!pendingOffer || isPurchasingRef.current) return;
|
||||||
|
if(giftMode && !giftRecipient.trim()) return;
|
||||||
|
if(isSelfGift) return;
|
||||||
|
|
||||||
isPurchasingRef.current = true;
|
isPurchasingRef.current = true;
|
||||||
|
wasGiftPurchaseRef.current = giftMode;
|
||||||
setPurchaseState(CatalogPurchaseState.PURCHASE);
|
setPurchaseState(CatalogPurchaseState.PURCHASE);
|
||||||
|
setGiftError(null);
|
||||||
|
setGiftSuccess(false);
|
||||||
|
|
||||||
|
if(giftMode)
|
||||||
|
{
|
||||||
|
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(currentPage.pageId, pendingOffer.offerId, '', giftRecipient.trim(), '', 0, 0, 0, false));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
|
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
|
||||||
}, [ pendingOffer, currentPage ]);
|
}
|
||||||
|
}, [ pendingOffer, currentPage, giftMode, giftRecipient, isSelfGift ]);
|
||||||
|
|
||||||
const setOffer = useCallback((offer: ClubOfferData) =>
|
const setOffer = useCallback((offer: ClubOfferData) =>
|
||||||
{
|
{
|
||||||
setPurchaseState(CatalogPurchaseState.NONE);
|
setPurchaseState(CatalogPurchaseState.NONE);
|
||||||
setPendingOffer(offer);
|
setPendingOffer(offer);
|
||||||
|
setGiftError(null);
|
||||||
|
setGiftSuccess(false);
|
||||||
|
|
||||||
|
if(!offer?.giftable) setGiftMode(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onGiftRecipientChange = useCallback((value: string) =>
|
||||||
|
{
|
||||||
|
setGiftRecipient(value);
|
||||||
|
setGiftError(null);
|
||||||
|
setGiftSuccess(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getPurchaseButton = useCallback(() =>
|
const getPurchaseButton = useCallback(() =>
|
||||||
@@ -115,19 +173,22 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
|||||||
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + pendingOffer.priceActivityPointsType) }</Button>;
|
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + pendingOffer.priceActivityPointsType) }</Button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const giftBlocked = giftMode && (!giftRecipient.trim() || isSelfGift);
|
||||||
|
const buyLabel = giftMode ? LocalizeText('catalog.gift_wrapping.give_gift') : LocalizeText('buy');
|
||||||
|
|
||||||
switch(purchaseState)
|
switch(purchaseState)
|
||||||
{
|
{
|
||||||
case CatalogPurchaseState.CONFIRM:
|
case CatalogPurchaseState.CONFIRM:
|
||||||
return <Button fullWidth variant="warning" onClick={ purchaseSubscription }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
|
return <Button disabled={ giftBlocked } fullWidth variant="warning" onClick={ purchaseSubscription }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
|
||||||
case CatalogPurchaseState.PURCHASE:
|
case CatalogPurchaseState.PURCHASE:
|
||||||
return <Button disabled fullWidth variant="primary"><LayoutLoadingSpinnerView /></Button>;
|
return <Button disabled fullWidth variant="primary"><LayoutLoadingSpinnerView /></Button>;
|
||||||
case CatalogPurchaseState.FAILED:
|
case CatalogPurchaseState.FAILED:
|
||||||
return <Button disabled fullWidth variant="danger">{ LocalizeText('generic.failed') }</Button>;
|
return <Button disabled fullWidth variant="danger">{ LocalizeText('generic.failed') }</Button>;
|
||||||
case CatalogPurchaseState.NONE:
|
case CatalogPurchaseState.NONE:
|
||||||
default:
|
default:
|
||||||
return <Button fullWidth variant="success" onClick={ () => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('buy') }</Button>;
|
return <Button disabled={ giftBlocked } fullWidth variant="success" onClick={ () => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ buyLabel }</Button>;
|
||||||
}
|
}
|
||||||
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount ]);
|
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount, giftMode, giftRecipient, isSelfGift ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid>
|
<Grid>
|
||||||
@@ -135,25 +196,29 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
|||||||
<AutoGrid className="nitro-catalog-layout-vip-buy-grid" columnCount={ 1 }>
|
<AutoGrid className="nitro-catalog-layout-vip-buy-grid" columnCount={ 1 }>
|
||||||
{ offers && (offers.length > 0) && offers.map((offer, index) =>
|
{ offers && (offers.length > 0) && offers.map((offer, index) =>
|
||||||
{
|
{
|
||||||
|
const isActive = (pendingOffer === offer);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-1" column={ false } itemActive={ pendingOffer === offer } justifyContent="between" onClick={ () => setOffer(offer) }>
|
<div key={ index } className={ 'nitro-vip-buy-offer flex flex-col gap-1.5 p-2 rounded-md border-2 cursor-pointer ' + (isActive ? 'active border-[#7a5500] bg-[#ffe066]' : 'border-[#b48a18] bg-[#fffbe7] hover:bg-[#fff5c4] hover:border-[#9c7610]') } onClick={ () => setOffer(offer) }>
|
||||||
<i className="icon-hc-banner" />
|
<div className="vip-offer-header flex items-center gap-2 pb-1.5 border-b border-dashed border-[#b48a18]">
|
||||||
<Column gap={ 0 } justifyContent="end">
|
<span className="vip-offer-banner inline-flex items-center justify-center shrink-0 w-[34px] h-[20px]">
|
||||||
<Text textEnd>{ getOfferText(offer) }</Text>
|
<i className="nitro-icon icon-hc-banner" style={ { width: '34px', height: '20px', backgroundSize: 'contain', backgroundRepeat: 'no-repeat', backgroundPosition: 'center' } } />
|
||||||
<Flex gap={ 1 } justifyContent="end">
|
</span>
|
||||||
|
<span className="vip-offer-title flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-bold text-[1.05rem] leading-tight text-[#2c2a25]">{ getOfferText(offer) }</span>
|
||||||
|
</div>
|
||||||
|
<div className="vip-offer-prices flex flex-col gap-1">
|
||||||
{ (offer.priceCredits > 0) &&
|
{ (offer.priceCredits > 0) &&
|
||||||
<Flex alignItems="center" gap={ 1 } justifyContent="end">
|
<span className="vip-offer-price flex items-center gap-1.5 font-bold text-[0.95rem] leading-tight text-[#4a473e] whitespace-nowrap">
|
||||||
<Text>{ offer.priceCredits }</Text>
|
|
||||||
<LayoutCurrencyIcon type={ -1 } />
|
<LayoutCurrencyIcon type={ -1 } />
|
||||||
</Flex> }
|
<span>{ offer.priceCredits }</span>
|
||||||
|
</span> }
|
||||||
{ (offer.priceActivityPoints > 0) &&
|
{ (offer.priceActivityPoints > 0) &&
|
||||||
<Flex alignItems="center" gap={ 1 } justifyContent="end">
|
<span className="vip-offer-price flex items-center gap-1.5 font-bold text-[0.95rem] leading-tight text-[#4a473e] whitespace-nowrap">
|
||||||
<Text>{ offer.priceActivityPoints }</Text>
|
|
||||||
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
|
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
|
||||||
</Flex> }
|
<span>{ offer.priceActivityPoints }</span>
|
||||||
</Flex>
|
</span> }
|
||||||
</Column>
|
</div>
|
||||||
</LayoutGridItem>
|
</div>
|
||||||
);
|
);
|
||||||
}) }
|
}) }
|
||||||
</AutoGrid>
|
</AutoGrid>
|
||||||
@@ -168,7 +233,7 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
|||||||
<Column fullWidth grow justifyContent="end">
|
<Column fullWidth grow justifyContent="end">
|
||||||
<Flex alignItems="end">
|
<Flex alignItems="end">
|
||||||
<Column grow gap={ 0 }>
|
<Column grow gap={ 0 }>
|
||||||
<Text fontWeight="bold">{ getPurchaseHeader() }</Text>
|
<Text fontWeight="bold">{ giftMode ? LocalizeText('catalog.purchase_confirmation.gift') : getPurchaseHeader() }</Text>
|
||||||
<Text>{ getPurchaseValidUntil() }</Text>
|
<Text>{ getPurchaseValidUntil() }</Text>
|
||||||
</Column>
|
</Column>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
@@ -184,6 +249,28 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
|
|||||||
</Flex> }
|
</Flex> }
|
||||||
</div>
|
</div>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
{ pendingOffer.giftable &&
|
||||||
|
<Column className="mt-1" gap={ 1 }>
|
||||||
|
<Flex alignItems="center" gap={ 2 }>
|
||||||
|
<label className="flex items-center gap-1 cursor-pointer text-sm">
|
||||||
|
<input checked={ giftMode } className="cursor-pointer" type="checkbox" onChange={ event => { setGiftMode(event.target.checked); setGiftError(null); setGiftSuccess(false); } } />
|
||||||
|
<span>{ LocalizeText('catalog.purchase_confirmation.gift') }</span>
|
||||||
|
</label>
|
||||||
|
{ giftMode &&
|
||||||
|
<input
|
||||||
|
className="flex-1 min-w-0 border border-[#b48a18] bg-white rounded px-2 py-1 text-sm"
|
||||||
|
placeholder={ LocalizeText('catalog.gift_wrapping.receiver') }
|
||||||
|
type="text"
|
||||||
|
value={ giftRecipient }
|
||||||
|
onChange={ event => onGiftRecipientChange(event.target.value) } /> }
|
||||||
|
</Flex>
|
||||||
|
{ giftMode && isSelfGift &&
|
||||||
|
<Text className="text-[#b00020] text-xs">{ LocalizeText('catalog.gift_wrapping.cannot_send_to_self') }</Text> }
|
||||||
|
{ giftMode && giftError && !isSelfGift &&
|
||||||
|
<Text className="text-[#b00020] text-xs">{ giftError }</Text> }
|
||||||
|
{ giftSuccess &&
|
||||||
|
<Text className="text-[#1f7a1f] text-sm font-bold">{ LocalizeText('catalog.gift_wrapping.gift_sent') }</Text> }
|
||||||
|
</Column> }
|
||||||
{ getPurchaseButton() }
|
{ getPurchaseButton() }
|
||||||
</Column> }
|
</Column> }
|
||||||
</Column>
|
</Column>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.nitro-catalog-layout-vip-buy-grid .nitro-vip-buy-offer {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid #b48a18;
|
||||||
|
background: #fffbe7;
|
||||||
|
color: #2c2a25;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 100ms ease-out, border-color 100ms ease-out, box-shadow 100ms ease-out;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-catalog-layout-vip-buy-grid .nitro-vip-buy-offer:hover {
|
||||||
|
background: #fff5c4;
|
||||||
|
border-color: #9c7610;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-catalog-layout-vip-buy-grid .nitro-vip-buy-offer.active {
|
||||||
|
background: #ffe066;
|
||||||
|
border-color: #7a5500;
|
||||||
|
box-shadow: inset 0 0 0 1px #ffd92e, 0 2px 4px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-vip-buy-offer .vip-offer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px dashed #b48a18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-vip-buy-offer .vip-offer-banner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 34px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-vip-buy-offer .vip-offer-banner .nitro-icon.icon-hc-banner,
|
||||||
|
.nitro-vip-buy-offer .vip-offer-banner i.icon-hc-banner {
|
||||||
|
background-size: contain !important;
|
||||||
|
background-repeat: no-repeat !important;
|
||||||
|
background-position: center !important;
|
||||||
|
width: 34px !important;
|
||||||
|
height: 20px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-vip-buy-offer .vip-offer-title {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2c2a25;
|
||||||
|
line-height: 1.1;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-vip-buy-offer .vip-offer-prices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-vip-buy-offer .vip-offer-price {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #4a473e;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nitro-vip-buy-offer .vip-offer-price .nitro-currency-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
@@ -1,33 +1,38 @@
|
|||||||
import { ClubOfferData, GetClubOffersMessageComposer, HabboClubOffersMessageEvent } from '@nitrots/nitro-renderer';
|
import { ClubOfferData, GetClubOffersMessageComposer, HabboClubOffersMessageEvent } from '@nitrots/nitro-renderer';
|
||||||
import { UseQueryResult } from '@tanstack/react-query';
|
import { useEffect } from 'react';
|
||||||
import { useNitroQuery } from '../../api/nitro-query';
|
import { SendMessageComposer } from '../../api';
|
||||||
|
import { useMessageEventState } from '../events';
|
||||||
|
|
||||||
|
const offersCache = new Map<number, ClubOfferData[]>();
|
||||||
|
|
||||||
/**
|
|
||||||
* Habbo Club offer list keyed by Catalog `windowId`. windowId 1 is the
|
|
||||||
* VIP buy page; 2 / 3 are the Builders Club / Builders Club Addons
|
|
||||||
* pages. Each catalog layout asks the server for its own slice via
|
|
||||||
* GetClubOffersMessageComposer(windowId) — the server replies with a
|
|
||||||
* HabboClubOffersMessageEvent carrying parser.windowId + parser.offers.
|
|
||||||
*
|
|
||||||
* Wrapped as a TanStack query so multiple consumers reading the same
|
|
||||||
* windowId share one request, and reopening the page within the
|
|
||||||
* session-stable cache window doesn't re-fetch.
|
|
||||||
*
|
|
||||||
* The accept() predicate filters out responses tagged with a different
|
|
||||||
* windowId — the renderer multiplexes the same event for every page,
|
|
||||||
* so without the filter a slow VIP response would land in a Builders
|
|
||||||
* Club query.
|
|
||||||
*/
|
|
||||||
export const useClubOffers = (
|
export const useClubOffers = (
|
||||||
windowId: number,
|
windowId: number,
|
||||||
options: { enabled?: boolean } = {}
|
options: { enabled?: boolean } = {}
|
||||||
): UseQueryResult<ClubOfferData[]> =>
|
): { data: ClubOfferData[] | null } =>
|
||||||
useNitroQuery<HabboClubOffersMessageEvent, ClubOfferData[]>({
|
{
|
||||||
key: [ 'nitro', 'catalog', 'clubOffers', windowId ],
|
const enabled = options.enabled !== false;
|
||||||
request: () => new GetClubOffersMessageComposer(windowId),
|
|
||||||
parser: HabboClubOffersMessageEvent,
|
const data = useMessageEventState<HabboClubOffersMessageEvent, ClubOfferData[] | null>(
|
||||||
accept: event => (event.getParser().windowId === windowId),
|
HabboClubOffersMessageEvent,
|
||||||
select: event => (event.getParser().offers || []),
|
event =>
|
||||||
enabled: options.enabled,
|
{
|
||||||
staleTime: Infinity
|
const parser = event.getParser();
|
||||||
});
|
if(!parser || parser.windowId !== windowId) return offersCache.get(windowId) ?? null;
|
||||||
|
|
||||||
|
const offers = parser.offers || [];
|
||||||
|
offersCache.set(windowId, offers);
|
||||||
|
return offers;
|
||||||
|
},
|
||||||
|
() => offersCache.get(windowId) ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(!enabled) return;
|
||||||
|
if(offersCache.has(windowId)) return;
|
||||||
|
|
||||||
|
SendMessageComposer(new GetClubOffersMessageComposer(windowId));
|
||||||
|
}, [ enabled, windowId ]);
|
||||||
|
|
||||||
|
return { data };
|
||||||
|
};
|
||||||
|
|||||||
@@ -15,19 +15,6 @@ const getTimeZeroPadded = (time: number) =>
|
|||||||
|
|
||||||
let modDisclaimerTimeout: ReturnType<typeof setTimeout> = null;
|
let modDisclaimerTimeout: ReturnType<typeof setTimeout> = null;
|
||||||
const recentBadgeNotifications = new Set<string>();
|
const recentBadgeNotifications = new Set<string>();
|
||||||
|
|
||||||
/**
|
|
||||||
* Internal singleton state + actions for the notification subsystem.
|
|
||||||
* Public consumers should reach for useNotificationState (read-only —
|
|
||||||
* the queue arrays for the renderer) or useNotificationActions (the
|
|
||||||
* imperative simpleAlert / showConfirm / showSingleBubble / etc.).
|
|
||||||
* useNotification is the legacy shim that composes both.
|
|
||||||
*
|
|
||||||
* Wrapped in useBetween at each public-hook layer so all consumers see
|
|
||||||
* the same instance, matching the previous useBetween(useNotificationState)
|
|
||||||
* behavior — required because ~30 useMessageEvent listeners live inside
|
|
||||||
* this hook and need to register exactly once across the tree.
|
|
||||||
*/
|
|
||||||
const useNotificationStore = () =>
|
const useNotificationStore = () =>
|
||||||
{
|
{
|
||||||
const [ alerts, setAlerts ] = useState<NotificationAlertItem[]>([]);
|
const [ alerts, setAlerts ] = useState<NotificationAlertItem[]>([]);
|
||||||
@@ -242,7 +229,6 @@ const useNotificationStore = () =>
|
|||||||
{
|
{
|
||||||
const parser = event.getParser();
|
const parser = event.getParser();
|
||||||
|
|
||||||
// Skip if BadgeReceivedEvent already showed a notification for this badge
|
|
||||||
if(recentBadgeNotifications.has(parser.data.badgeCode)) return;
|
if(recentBadgeNotifications.has(parser.data.badgeCode)) return;
|
||||||
|
|
||||||
recentBadgeNotifications.add(parser.data.badgeCode);
|
recentBadgeNotifications.add(parser.data.badgeCode);
|
||||||
@@ -258,7 +244,6 @@ const useNotificationStore = () =>
|
|||||||
{
|
{
|
||||||
const parser = event.getParser();
|
const parser = event.getParser();
|
||||||
|
|
||||||
// Skip if AchievementNotificationMessageEvent already showed a notification for this badge
|
|
||||||
if(recentBadgeNotifications.has(parser.badgeCode)) return;
|
if(recentBadgeNotifications.has(parser.badgeCode)) return;
|
||||||
|
|
||||||
recentBadgeNotifications.add(parser.badgeCode);
|
recentBadgeNotifications.add(parser.badgeCode);
|
||||||
@@ -266,9 +251,6 @@ const useNotificationStore = () =>
|
|||||||
|
|
||||||
const badgeName = LocalizeBadgeName(parser.badgeCode);
|
const badgeName = LocalizeBadgeName(parser.badgeCode);
|
||||||
const badgeImage = GetSessionDataManager().getBadgeUrl(parser.badgeCode);
|
const badgeImage = GetSessionDataManager().getBadgeUrl(parser.badgeCode);
|
||||||
// senderName is non-empty only when a staff member awarded the badge
|
|
||||||
// via the `:badge` command. Empty for achievements, catalog buys,
|
|
||||||
// wired rewards, poll rewards, etc.
|
|
||||||
const senderName = parser.senderName || '';
|
const senderName = parser.senderName || '';
|
||||||
|
|
||||||
showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.badgeCode, senderName);
|
showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.badgeCode, senderName);
|
||||||
@@ -392,8 +374,7 @@ const useNotificationStore = () =>
|
|||||||
{
|
{
|
||||||
const parser = event.getParser();
|
const parser = event.getParser();
|
||||||
|
|
||||||
// Skip badge notifications — handled by BadgeReceivedEvent with "Wear" button
|
if(parser.type === 'badge_received' || parser.type === 'badges') return;
|
||||||
if(parser.type === 'badge_received' || parser.type === 'badges' || parser.type.includes('badge')) return;
|
|
||||||
|
|
||||||
showNotification(parser.type, parser.parameters);
|
showNotification(parser.type, parser.parameters);
|
||||||
});
|
});
|
||||||
@@ -512,14 +493,6 @@ const useNotificationStore = () =>
|
|||||||
return { alerts, bubbleAlerts, confirms, simpleAlert, showNitroAlert, showTradeAlert, showConfirm, showSingleBubble, closeAlert, closeBubbleAlert, closeConfirm };
|
return { alerts, bubbleAlerts, confirms, simpleAlert, showNitroAlert, showTradeAlert, showConfirm, showSingleBubble, closeAlert, closeBubbleAlert, closeConfirm };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Read-only slice of the notification store: the three queue arrays
|
|
||||||
* (alerts, bubbleAlerts, confirms) that the renderer view layer drains.
|
|
||||||
*
|
|
||||||
* Consumers that only need to *show* a notification should use
|
|
||||||
* useNotificationActions instead — the queues are an implementation
|
|
||||||
* detail of the global NotificationView component.
|
|
||||||
*/
|
|
||||||
export const useNotificationState = () =>
|
export const useNotificationState = () =>
|
||||||
{
|
{
|
||||||
const { alerts, bubbleAlerts, confirms } = useBetween(useNotificationStore);
|
const { alerts, bubbleAlerts, confirms } = useBetween(useNotificationStore);
|
||||||
@@ -527,14 +500,6 @@ export const useNotificationState = () =>
|
|||||||
return { alerts, bubbleAlerts, confirms };
|
return { alerts, bubbleAlerts, confirms };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Imperative slice of the notification store: 8 entry points covering
|
|
||||||
* the alert / bubble / confirm / trade-alert flows plus the matching
|
|
||||||
* close handlers. ~40 consumers across the codebase only use one or
|
|
||||||
* two of these — splitting the slice off keeps their dependency
|
|
||||||
* surface honest and makes it greppable which call sites
|
|
||||||
* dismiss-vs-show.
|
|
||||||
*/
|
|
||||||
export const useNotificationActions = () =>
|
export const useNotificationActions = () =>
|
||||||
{
|
{
|
||||||
const {
|
const {
|
||||||
@@ -560,10 +525,4 @@ export const useNotificationActions = () =>
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Prefer `useNotificationState` (queue arrays) and
|
|
||||||
* `useNotificationActions` (imperative show/close helpers) directly.
|
|
||||||
* This shim composes both into the historical `useNotification()`
|
|
||||||
* shape so the existing 40+ consumers keep working unchanged.
|
|
||||||
*/
|
|
||||||
export const useNotification = () => useBetween(useNotificationStore);
|
export const useNotification = () => useBetween(useNotificationStore);
|
||||||
|
|||||||
Reference in New Issue
Block a user