diff --git a/public/configuration/UITexts.example b/public/configuration/UITexts.example index 2b87ccb..cf24e72 100644 --- a/public/configuration/UITexts.example +++ b/public/configuration/UITexts.example @@ -265,4 +265,5 @@ "loading.task.userdata": "loading user data...", "loading.task.rooms": "loading rooms...", "loading.task.engine": "loading graphics engine...", + "catalog.gift_wrapping.gift_sent": "Done!" } diff --git a/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx index 09202de..0bce674 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutVipBuyView.tsx @@ -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 { 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 { useCatalogData, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks'; +import { useCatalogData, useClubOffers, useMessageEvent, usePurse, useUiEvent, useUserDataSnapshot } from '../../../../../hooks'; import { CatalogLayoutProps } from './CatalogLayout.types'; const VIP_WINDOW_ID = 1; @@ -12,11 +12,19 @@ export const CatalogLayoutVipBuyView: FC = props => { const [ pendingOffer, setPendingOffer ] = useState(null); const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE); + const [ giftMode, setGiftMode ] = useState(false); + const [ giftRecipient, setGiftRecipient ] = useState(''); + const [ giftError, setGiftError ] = useState(null); + const [ giftSuccess, setGiftSuccess ] = useState(false); const { currentPage = null } = useCatalogData(); const { purse = null, getCurrencyAmount = null } = usePurse(); const { data: offers = null } = useClubOffers(VIP_WINDOW_ID); + const { userName: ownUserName = '' } = useUserDataSnapshot(); const isPurchasingRef = useRef(false); + const wasGiftPurchaseRef = useRef(false); + const giftSuccessTimerRef = useRef | null>(null); + const isSelfGift = giftMode && !!ownUserName && giftRecipient.trim().toLowerCase() === ownUserName.toLowerCase(); const onCatalogEvent = useCallback((event: CatalogEvent) => { switch(event.type) @@ -24,9 +32,20 @@ export const CatalogLayoutVipBuyView: FC = props => case CatalogPurchasedEvent.PURCHASE_SUCCESS: isPurchasingRef.current = false; 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; case CatalogPurchaseFailureEvent.PURCHASE_FAILED: isPurchasingRef.current = false; + wasGiftPurchaseRef.current = false; setPurchaseState(CatalogPurchaseState.FAILED); return; } @@ -35,6 +54,21 @@ export const CatalogLayoutVipBuyView: FC = props => useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, 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, handleGiftReceiverNotFound); + const getOfferText = useCallback((offer: ClubOfferData) => { let offerText = ''; @@ -89,16 +123,40 @@ export const CatalogLayoutVipBuyView: FC = props => const purchaseSubscription = useCallback(() => { if(!pendingOffer || isPurchasingRef.current) return; + if(giftMode && !giftRecipient.trim()) return; + if(isSelfGift) return; isPurchasingRef.current = true; + wasGiftPurchaseRef.current = giftMode; setPurchaseState(CatalogPurchaseState.PURCHASE); - SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1)); - }, [ pendingOffer, currentPage ]); + 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)); + } + }, [ pendingOffer, currentPage, giftMode, giftRecipient, isSelfGift ]); const setOffer = useCallback((offer: ClubOfferData) => { setPurchaseState(CatalogPurchaseState.NONE); 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(() => @@ -115,19 +173,22 @@ export const CatalogLayoutVipBuyView: FC = props => return ; } + const giftBlocked = giftMode && (!giftRecipient.trim() || isSelfGift); + const buyLabel = giftMode ? LocalizeText('catalog.gift_wrapping.give_gift') : LocalizeText('buy'); + switch(purchaseState) { case CatalogPurchaseState.CONFIRM: - return ; + return ; case CatalogPurchaseState.PURCHASE: return ; case CatalogPurchaseState.FAILED: return ; case CatalogPurchaseState.NONE: default: - return ; + return ; } - }, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount ]); + }, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount, giftMode, giftRecipient, isSelfGift ]); return ( @@ -135,25 +196,29 @@ export const CatalogLayoutVipBuyView: FC = props => { offers && (offers.length > 0) && offers.map((offer, index) => { + const isActive = (pendingOffer === offer); + return ( - setOffer(offer) }> - - - { getOfferText(offer) } - - { (offer.priceCredits > 0) && - - { offer.priceCredits } - - } - { (offer.priceActivityPoints > 0) && - - { offer.priceActivityPoints } - - } - - - +
setOffer(offer) }> +
+ + + + { getOfferText(offer) } +
+
+ { (offer.priceCredits > 0) && + + + { offer.priceCredits } + } + { (offer.priceActivityPoints > 0) && + + + { offer.priceActivityPoints } + } +
+
); }) }
@@ -168,7 +233,7 @@ export const CatalogLayoutVipBuyView: FC = props => - { getPurchaseHeader() } + { giftMode ? LocalizeText('catalog.purchase_confirmation.gift') : getPurchaseHeader() } { getPurchaseValidUntil() }
@@ -184,6 +249,28 @@ export const CatalogLayoutVipBuyView: FC = props => }
+ { pendingOffer.giftable && + + + + { giftMode && + onGiftRecipientChange(event.target.value) } /> } + + { giftMode && isSelfGift && + { LocalizeText('catalog.gift_wrapping.cannot_send_to_self') } } + { giftMode && giftError && !isSelfGift && + { giftError } } + { giftSuccess && + { LocalizeText('catalog.gift_wrapping.gift_sent') } } + } { getPurchaseButton() }
} diff --git a/src/css/catalog/CatalogVipBuyView.css b/src/css/catalog/CatalogVipBuyView.css new file mode 100644 index 0000000..1534aaa --- /dev/null +++ b/src/css/catalog/CatalogVipBuyView.css @@ -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; +} diff --git a/src/hooks/catalog/useClubOffers.ts b/src/hooks/catalog/useClubOffers.ts index 964f6d4..6b7c020 100644 --- a/src/hooks/catalog/useClubOffers.ts +++ b/src/hooks/catalog/useClubOffers.ts @@ -1,33 +1,38 @@ import { ClubOfferData, GetClubOffersMessageComposer, HabboClubOffersMessageEvent } from '@nitrots/nitro-renderer'; -import { UseQueryResult } from '@tanstack/react-query'; -import { useNitroQuery } from '../../api/nitro-query'; +import { useEffect } from 'react'; +import { SendMessageComposer } from '../../api'; +import { useMessageEventState } from '../events'; + +const offersCache = new Map(); -/** - * 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 = ( windowId: number, options: { enabled?: boolean } = {} -): UseQueryResult => - useNitroQuery({ - key: [ 'nitro', 'catalog', 'clubOffers', windowId ], - request: () => new GetClubOffersMessageComposer(windowId), - parser: HabboClubOffersMessageEvent, - accept: event => (event.getParser().windowId === windowId), - select: event => (event.getParser().offers || []), - enabled: options.enabled, - staleTime: Infinity - }); +): { data: ClubOfferData[] | null } => +{ + const enabled = options.enabled !== false; + + const data = useMessageEventState( + HabboClubOffersMessageEvent, + event => + { + 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 }; +}; diff --git a/src/hooks/notification/useNotification.ts b/src/hooks/notification/useNotification.ts index ca1e970..d7d3562 100644 --- a/src/hooks/notification/useNotification.ts +++ b/src/hooks/notification/useNotification.ts @@ -15,19 +15,6 @@ const getTimeZeroPadded = (time: number) => let modDisclaimerTimeout: ReturnType = null; const recentBadgeNotifications = new Set(); - -/** - * 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 [ alerts, setAlerts ] = useState([]); @@ -242,7 +229,6 @@ const useNotificationStore = () => { const parser = event.getParser(); - // Skip if BadgeReceivedEvent already showed a notification for this badge if(recentBadgeNotifications.has(parser.data.badgeCode)) return; recentBadgeNotifications.add(parser.data.badgeCode); @@ -258,7 +244,6 @@ const useNotificationStore = () => { const parser = event.getParser(); - // Skip if AchievementNotificationMessageEvent already showed a notification for this badge if(recentBadgeNotifications.has(parser.badgeCode)) return; recentBadgeNotifications.add(parser.badgeCode); @@ -266,9 +251,6 @@ const useNotificationStore = () => const badgeName = LocalizeBadgeName(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 || ''; showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.badgeCode, senderName); @@ -392,8 +374,7 @@ const useNotificationStore = () => { const parser = event.getParser(); - // Skip badge notifications — handled by BadgeReceivedEvent with "Wear" button - if(parser.type === 'badge_received' || parser.type === 'badges' || parser.type.includes('badge')) return; + if(parser.type === 'badge_received' || parser.type === 'badges') return; showNotification(parser.type, parser.parameters); }); @@ -512,14 +493,6 @@ const useNotificationStore = () => 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 = () => { const { alerts, bubbleAlerts, confirms } = useBetween(useNotificationStore); @@ -527,14 +500,6 @@ export const useNotificationState = () => 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 = () => { 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);