Files
Nitro-V3/src/components/catalog/views/catalog-header/CatalogBuildersClubStatusView.tsx
T
simoleo89 59d6c4cab3 catalog: three-way singleton-filter split + first 3 consumer migrations
Completes the useCatalog decomposition. After the previous commit
extracted the pure helpers, this one splits the singleton-via-useBetween
store into three slice-specific entry points and migrates a handful of
consumers as proof.

`src/hooks/catalog/useCatalog.ts`

- Internal `useCatalogState` → renamed to `useCatalogStore` and is no
  longer exported. The full return shape is unchanged so callers that
  still go through the shim see the exact same object.
- Three new exports built on top of the same `useBetween` instance:
    - `useCatalogData()` — server-driven read-only slice (rootNode,
      offersToNodes, currentPage, currentOffer, frontPageItems,
      searchResult, roomPreviewer, isBusy, catalog localization
      version, Builders Club counters + timers).
    - `useCatalogUiState()` — UI ephemeral state + writers
      (isVisible, pageId, previousPageId, currentType, activeNodes,
      navigationHidden, purchaseOptions, catalogPlaceMultipleObjects,
      plus every `set*` writer including the ones that mutate the
      data slice on user-driven selection).
    - `useCatalogActions()` — imperative operations only
      (openCatalogByType, toggleCatalogByType, activateNode,
      openPageBy{Id,Name,OfferId}, requestOfferToMover,
      selectCatalogOffer, getNodeBy{Id,Name},
      getBuilderFurniPlaceableStatus).
- `useCatalog` is kept as a deprecated shim that returns the full
  historical surface, so the 48 existing consumers compile and run
  unchanged.

Pilot consumer migrations (3 of 48):

- `CatalogBuildersClubStatusView` — Data (furni counters, seconds
  timers) + UiState (currentType).
- `CatalogBreadcrumbView` — UiState (activeNodes) + Actions
  (activateNode).
- `CatalogNavigationItemView` — UiState (currentType) + Actions
  (activateNode).

Tests: `tests/useCatalog.filters.test.tsx` (5 cases).

`useBetween` is mocked via `vi.hoisted` so the four hooks share one
deterministic fake store — rendering the real `useCatalogStore`
would mount ~30 useState calls + open a fresh RoomPreviewer +
subscribe to a dozen renderer events, which is more than these
contract tests need.

- `useCatalogData` exposes exactly its read-only keys.
- `useCatalogUiState` exposes exactly its UI keys + setters.
- `useCatalogActions` exposes exactly its imperative ops (and
  explicitly NOT data fields — proves no leak across slices).
- Singleton identity: callbacks read through the shim are `===` to
  the ones read through the slices.
- Shim surface: the historical key set is still present so
  un-migrated consumers don't silently break.

Suite: 163/163 (was 158/158). `yarn typecheck` green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 21:50:56 +02:00

88 lines
3.3 KiB
TypeScript

import { GetTickerTime } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { CatalogType, FriendlyTime, LocalizeText } from '../../../../api';
import buildersClubIcon from '../../../../assets/images/toolbar/icons/buildersclub.png';
import { useCatalogData, useCatalogUiState } from '../../../../hooks';
export const CatalogBuildersClubStatusView: FC = () =>
{
const { furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalogData();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const [ ticker, setTicker ] = useState(() => GetTickerTime());
useEffect(() =>
{
if(currentType !== CatalogType.BUILDER) return;
const interval = window.setInterval(() => setTicker(GetTickerTime()), 1000);
return () => window.clearInterval(interval);
}, [ currentType ]);
const localizeOrDefault = (key: string, fallback: string, parameters: string[] = [], values: string[] = []) =>
{
const localized = LocalizeText(key, parameters, values);
return ((localized && (localized !== key)) ? localized : fallback);
};
const remainingSeconds = useMemo(() =>
{
const baseSeconds = (secondsLeft > 0) ? secondsLeft : secondsLeftWithGrace;
if(baseSeconds <= 0) return 0;
const elapsed = ((updateTime > 0) ? Math.floor((ticker - updateTime) / 1000) : 0);
return Math.max(0, (baseSeconds - elapsed));
}, [ secondsLeft, secondsLeftWithGrace, ticker, updateTime ]);
const isFullMember = (secondsLeft > 0);
const membershipStatus = localizeOrDefault(
isFullMember ? 'builder.header.status.member' : 'builder.header.status.trial',
isFullMember ? 'Membro Completo' : 'Prova Gratuita'
);
const title = localizeOrDefault(
'builder.header.title',
`Stato Builders' Club: ${ membershipStatus }`,
[ 'BCSTATUS' ],
[ membershipStatus ]
);
const durationText = localizeOrDefault(
'builder.header.status.membership',
`Tempo mancante: ${ FriendlyTime.format(remainingSeconds) }`,
[ 'DURATION' ],
[ FriendlyTime.format(remainingSeconds) ]
);
const limitText = localizeOrDefault(
'builder.header.status.limit',
`Furni usati: ${ furniCount }/${ furniLimit }`,
[ 'COUNT', 'LIMIT' ],
[ furniCount.toString(), furniLimit.toString() ]
);
if(currentType !== CatalogType.BUILDER) return null;
return (
<div className="builders-club-status-shell flex items-center gap-3 px-4 py-3">
<div className="builders-club-status-icon-shell flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-md">
<img alt="" className="h-[28px] w-[28px] object-contain" src={ buildersClubIcon } />
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[13px] leading-none font-bold text-white">
{ title }
</span>
<span className="mt-1 text-[11px] leading-tight text-white/95">
{ durationText }
</span>
<span className="text-[11px] leading-tight text-[#ffba45]">
{ limitText }
</span>
</div>
</div>
);
};