Commit Graph

52 Commits

Author SHA1 Message Date
simoleo89 c7e258e3d1 feat(hooks): permission-driven gating via useHasPermission
Replace the rank-level family (useHasRankLevel + STAFF_LEVELS
constants + useIsRank) with a permission-driven family that reads
straight from the deployment's `permission_definitions` table — no
more hardcoded SecurityLevel/rank-id thresholds on the client. A new
rank in permission_ranks or a re-shuffling of permission_definitions
rank columns now propagates through the UI automatically.

Renderer-side wire shipped in companion commit
feat/react19-event-bus@159c5eb (UserPermissionsMapParser + Event,
SessionDataManager.getPermissionsSnapshot + USER_PERMISSIONS_UPDATED).

New public API in `useSessionSnapshots.ts`:
- useUserPermissions(): ReadonlyMap<string, number>  — full map
- useHasPermission(key): boolean                       — > 0 ⇒ true
- usePermissionValue(key): number                      — raw 1/2 or 0
- useIsAmbassador() now aliases useHasPermission('acc_ambassador')
- useUserRank() kept for PRESENTATIONAL use only (badge, prefix,
  prefix color) — documented as such in JSDoc; gating must use
  useHasPermission.

Dropped:
- src/api/nitro/session/RankLevels.ts (STAFF_LEVELS constants)
- useHasRankLevel / useIsRank exports (rank-based gating)

11 consumer migrations, each mapped to the right
`permission_definitions.permission_key`:

  - ToolbarView (mod-only chat-input button)        → acc_supporttool
  - ChooserWidgetView (room-object id column)       → acc_supporttool
  - CatalogClassicView (admin toggles)              → acc_catalogfurni
  - CatalogModernView (admin toggles)               → acc_catalogfurni
  - FurniEditorView (panel access)                  → acc_catalogfurni
  - CalendarView (force-open day)                   → acc_calendar_force
  - InfoStandWidgetFurniView (mod buildtools btn)   → acc_anyroomowner
  - AvatarInfoWidgetPetView (canPickUp)             → acc_anyroomowner
  - FurnitureMannequinView (controller mode)        → acc_anyroomowner
  - YouTubePlayerView (isMyRoom)                    → acc_anyroomowner
  - NavigatorRoomInfoView 'settings'                → acc_anyroomowner
  - NavigatorRoomInfoView 'staff_pick'              → acc_staff_pick

Test refresh:
- useUserRank still tested for the presentational shape.
- useHasPermission: true for non-zero, false for absent/zero.
- usePermissionValue: raw 1 / 2 / 0 (default).
- useUserPermissions: full map exposure.
- Runtime promote test: mutate the permissions map + dispatch
  USER_PERMISSIONS_UPDATED, assert useHasPermission flips false→true.
  Locks in the new reactive contract.

Mock unchanged (the test sets getPermissionsSnapshot via vi.mocked).

Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
214/214 (213 prior + 1 net new for useUserPermissions). Backward
compatible: older Arcturus deployments don't ship the map → empty
snapshot → every gate is false → mod UI hidden (safe default).
2026-05-19 19:00:10 +02:00
simoleo89 8aa02249e1 feat(hooks): rank-based API tied to permission_ranks DB table
Drop the SecurityLevel-named family (useIsModerator / useIsAdmin /
useIsCommunity / useIsPlayerSupport / useHasSecurityLevel /
useUserSecurityLevel) in favour of a rank-based family tied to the
operator's actual `permission_ranks` rows in the Arcturus DB:

- `useUserRank()` returns `{ id, name, level, badge, prefix,
  prefixColor }` derived from the snapshot. Powered by the renderer's
  extended IUserDataSnapshot (companion commit 87e67d5 on
  feat/react19-event-bus).
- `useHasRankLevel(min)` replaces useHasSecurityLevel; consumers
  pass a `permission_ranks.level` threshold from the deployment.
- `useIsRank(name)` matches `permission_ranks.rank_name` exactly.

To avoid bare integers in widget bodies, added a deployment-scoped
constants file at `src/api/nitro/session/RankLevels.ts`:

  export const STAFF_LEVELS = {
      MEMBER: 1, SUPPORT: 4, MOD: 5, SUPER_MOD: 6, ADMIN: 7
  };

A deployment that re-numbers `permission_ranks` only edits this file.

Migrated all 11 consumer reads (same set as the earlier session's
useIsModerator migration plus the audit catch): ToolbarView,
CatalogClassicView, CatalogModernView, ChooserWidgetView,
CalendarView, YouTubePlayerView, FurniEditorView,
InfoStandWidgetFurniView, AvatarInfoWidgetPetView,
FurnitureMannequinView, NavigatorRoomInfoView. The
NavigatorRoomInfoView `staff_pick` permission was previously
`securityLevel >= COMMUNITY (7)` via the renderer-enum wrapper —
ported to `useHasRankLevel(STAFF_LEVELS.ADMIN)` because in the
default seed level 7 is Administrator, which is the actual rank that
gets the `acc_anyroomowner`-style permissions for staff-picking.

Tests refreshed under `useSessionSnapshots.test.tsx`:
- useUserRank surfaces the full metadata block;
- useHasRankLevel does `>=` against the threshold;
- useIsRank exact-matches against rank_name;
- a runtime promote (snapshot mutation + SESSION_DATA_UPDATED
  dispatch) flips the result, locking in the reactive contract.

Mock extended only minimally — kept the SecurityLevel enum class for
any consumer outside the dropped family that still imports it.

Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
213/213. The Arcturus-side composer change (UserPermissionsComposer
appending the 5 extra fields) is staged but UNCOMMITTED on Arcturus
main (which has unrelated WIP); the wire is backward-compatible so
the React client works against both pre- and post-extension
emulators.
2026-05-19 18:38:31 +02:00
simoleo89 532cb28ca7 feat(hooks): useIsModerator() + migrate 6 component reads
Adds a reactive `useIsModerator()` derived from
`useUserDataSnapshot().securityLevel >= SecurityLevel.MODERATOR`
(mirrors the renderer-side getter at SessionDataManager.ts:684), and
migrates the six React component-body reads of
`GetSessionDataManager().isModerator`:

- ToolbarView (mod-only chat-input button)
- CatalogClassicView, CatalogModernView (admin toggles in catalog
  header)
- ChooserWidgetView (room-object id column visibility)
- YouTubePlayerView (room-control affordance — hook moved above the
  `if (!isOpen) return null` early return so the hook order stays
  stable when the player opens/closes)
- CalendarView (mod-only "open all" affordance)

UX impact: any future promote/demote that flips
SESSION_DATA_UPDATED now re-renders the mod-only UI live, instead of
requiring an F5. Imperative call sites
(AvatarInfoUtilities.populate*, CanManipulateFurniture,
RoomChatHandler) still read the manager directly — they run at click
time, not in a React render, so reactivity has no upside there.

Five of the six call sites are top-level component-body reads (no
early-return interaction). YouTubePlayerView has an
`if (!isOpen) return null` below the hook list, so the hook had to
move ABOVE it; same shape as the recent CatalogPurchaseWidgetView and
CatalogItemGridWidgetView fixes.

Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
209/209.
2026-05-19 18:07:17 +02:00
simoleo89 a029ee63cb fix(catalog,ci): catch hook-order violations + add CI gate
Two follow-ups to the CatalogPurchaseWidgetView fix (6bf3366):

1. CatalogItemGridWidgetView had the same shape — four useCallback
   declarations (handleDragStart / handleDragOver / handleDrop /
   handleDragEnd) sat below an `if(!currentPage) return null` early
   return. When currentPage flipped from null to a real page the hook
   count jumped by 4 and React would have thrown "Rendered more hooks
   than during the previous render" the moment any consumer rendered
   the grid in admin mode. Moved the four useCallback declarations
   above the early-return; their bodies are safe pre-load (only
   currentPage?.offers is accessed inside handleDrop, optional-chained
   already).

2. CI gate — the existing GitHub Actions workflow runs `yarn
   typecheck` and `yarn test`, but NOT `yarn eslint`. That's why this
   pattern slipped through twice in a row: ESLint flags it locally
   but no PR check enforces it. Full `yarn eslint` emits ~900
   pre-existing baseline errors (brace-style, indentation,
   recommended TS rules — out of scope for this branch), so a blanket
   step would always fail. Instead added a focused
   `eslint.hooks.config.mjs` + `yarn lint:hooks` script that runs
   ESLint with ONLY `react-hooks/rules-of-hooks: error`. Wired into
   ci.yml between `typecheck` and `test`. The local repo now has
   zero violations of the rule.

3. useSessionSnapshots.test.tsx — added eslint-disable-next-line
   comments on the three lines that intentionally violate the rule
   (they're the assertions that the broken pattern crashes). Without
   the comments the new CI gate would fail on the regression-guard
   suite.

Verification: yarn lint:hooks green, yarn typecheck clean, yarn test
209/209.
2026-05-19 17:57:28 +02:00
simoleo89 6bf3366af7 fix(catalog): stabilise hook order in CatalogPurchaseWidgetView
React reported "Rendered more hooks than during the previous render"
when CatalogPurchaseWidgetView transitioned from currentOffer=null to
a real offer: hook count jumped from 22 to 23 because the
useMemo/useEffect block for the builders-club placement state sat
*below* the `if(!currentOffer) return null` early-return on line 140.
On the first render it never ran; on the next render (offer loaded)
it did, and React's hook-call tracker flagged the divergence and
unmounted the component via the error boundary.

Fix: move the three builders-club hooks (useMemo builderPlaceableStatus,
useMemo buildersClubPlaceOneButtonStyle, useEffect interval) above the
early return. They already short-circuit cleanly when
isBuildersClubPlaceable is false — added a defensive `!currentOffer`
guard on the first useMemo and an explicit `!!currentOffer` clause on
the derived isBuildersClubPlaceable so the .product access stays safe
when offer is null. Behavior unchanged for the loaded-offer path; the
early-render path now runs the hooks but their bodies no-op.

Verification: yarn typecheck clean, yarn test 209/209.
2026-05-19 17:43:20 +02:00
simoleo89 0f9fa1203b catalog: migrate remaining 36 useCatalog() consumers to the three filters
Replaces every direct call to the deprecated useCatalog() shim with the
targeted filter(s) (useCatalogData / useCatalogUiState / useCatalogActions).
Each consumer now subscribes only to the slice it actually reads, which
restores React Compiler memoization and stops catalog-wide re-renders
whenever an unrelated key changes.

Removes the now-unused useCatalog shim from useCatalog.ts and the
shim-specific case in tests/useCatalog.filters.test.tsx. The "all four
hooks observe the same singleton" test becomes "all three filters", since
there is no shim left to compare against. useCatalogFavorites swaps its
internal useCatalog() call for useCatalogUiState() (currentType lives in
the UI slice).

Updates CLAUDE.md and docs/ARCHITECTURE.md to reflect that all 48
historical consumers are migrated and the shim is gone.

Vitest: 162/162 (was 163 — minus the deprecated-shim contract case).
2026-05-14 20:05:44 +02:00
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
simoleo89 7b062299de useClubGifts + useNitroEventInvalidator: close the catalogOptions bag
This commit drains the last field out of ICatalogOptions (clubGifts)
and deletes the interface — useCatalog no longer owns a catch-all
mutable object that downstream components stuff data into.

Two pieces:

1) New useNitroEventInvalidator(eventType, queryKey, accept?) — a
   small companion to useNitroQuery for the case where the server
   pushes the same event unprompted (e.g. ClubGiftInfoEvent fires
   both as the response to GetClubGiftInfo and again after the user
   claims a gift via SelectClubGiftComposer). It calls
   queryClient.invalidateQueries() on each matching push so the
   next render of any subscriber triggers a fresh queryFn.

2) New useClubGifts() — useNitroQuery on the ClubGiftInfoEvent
   pair, paired with useNitroEventInvalidator so server-driven
   pushes refresh the cache automatically. CatalogLayoutVipGiftsView
   now consumes the query directly. The local optimistic
   'giftsAvailable--' mutation (which side-effected the parser
   object passed back to the catalog state!) is dropped — the
   server's authoritative ClubGiftInfoEvent push is the single
   source of truth via the invalidator.

useCatalog drops the matching listener + the GetClubGiftInfo dispatch
from the catalog-open effect. ICatalogOptions is now empty and
deleted; the catalogOptions / setCatalogOptions state + return-shape
field are removed from useCatalog along with the import.
2026-05-11 22:38:32 +02:00
simoleo89 9a807bf335 useMarketplaceConfiguration: lift the marketplace config self-fetch
MarketplacePostOfferView was both *the* fetcher and the listener for
MarketplaceConfigurationEvent — it dispatched
GetMarketplaceConfigurationMessageComposer from one effect when item
was set, then routed the response through setCatalogOptions.

useCatalog never touched the field; it was passing through catalogOptions
purely as a transport mechanism for this single component to talk to
itself. Replace with useMarketplaceConfiguration() — staleTime Infinity
(server-side constants for a session), enabled on item, single tidy
data path.

Drops marketplaceConfiguration from ICatalogOptions; with petPalettes
out too, ICatalogOptions is now just { clubGifts }. clubGifts is the
last one and needs invalidation (server pushes ClubGiftInfoEvent after
SelectClubGiftComposer) so it stays put until useNitroEventInvalidator
companion lands.
2026-05-11 22:32:35 +02:00
simoleo89 3947781495 useSellablePetPalette(breed): per-breed TanStack query for pet picker
CatalogLayoutPetView previously read 'catalogOptions.petPalettes' (an
accumulating array of CatalogPetPalette objects keyed by breed) and,
on cache miss, dispatched GetSellablePetPalettesComposer(productData.type)
inline. useCatalog kept the matching SellablePetPalettesMessageEvent
listener that appended each new breed to the array (deduping by breed
identity).

Migrate the request/response pair to a TanStack query parameterized on
breed:

  useSellablePetPalette(breed)
    key:     ['nitro', 'catalog', 'petPalette', breed]
    request: () => new GetSellablePetPalettesComposer(breed)
    parser:  SellablePetPalettesMessageEvent
    accept:  event.getParser().productCode === breed
    select:  build a CatalogPetPalette from parser
    enabled: !!breed (avoid spamming composers before currentOffer is set)
    staleTime: Infinity

The view now derives breed from currentOffer.product.productData.type
and reads 'const { data: petPalette }'. The cache-miss-then-fetch
two-pass effect collapses into a single effect that runs once
petPalette resolves (or clears state when offer/petPalette aren't
ready).

Drops the matching listener from useCatalog, drops petPalettes from
ICatalogOptions, and removes the now-unused CatalogPetPalette /
SellablePetPalettesMessageEvent imports from useCatalog.
2026-05-11 22:28:55 +02:00
simoleo89 2a5b9a4a98 useClubOffers: per-windowId TanStack query for HC offer pages
Two catalog layouts each fire 'new GetClubOffersMessageComposer(windowId)'
on mount and read parser.offers via HabboClubOffersMessageEvent:

  - CatalogLayoutVipBuyView (windowId 1)
  - CatalogLayoutBuildersClubBuyView (windowId 2 / 3, depending on
    the addon variant)

Plus useCatalog used to also listen for HabboClubOffersMessageEvent and
stash the offers in 'catalogOptions.clubOffersByWindowId[windowId]' and
'catalogOptions.clubOffers' (the latter being a backward-compat alias
for windowId 1). Three listeners, three independent requests when all
mounted.

New useClubOffers(windowId) wraps the request/response pair as a
TanStack query keyed by '['nitro', 'catalog', 'clubOffers', windowId]'.
accept(): correlation-key filter (parser.windowId === windowId) so
the same multiplexed event doesn't satisfy the wrong query slot.

Both layouts now read 'const { data: offers = null } = useClubOffers(windowId)';
useCatalog drops the listener, ICatalogOptions drops the
clubOffers / clubOffersByWindowId fields and HabboClubOffersMessageEvent
no longer needs to be imported in useCatalog. The localization-refresh
effect that re-cloned both fields is also dropped — React Query owns
the cache now, and ClubOfferData has no localized strings anyway.
2026-05-11 22:23:19 +02:00
simoleo89 2d9785e931 useUserGroups: consolidate 4 dedup'd CatalogGroupsComposer call sites
Four independent components used to send 'new CatalogGroupsComposer()'
on mount and listen for GuildMembershipsMessageEvent:

  - useCatalog (writing into catalogOptions.groups)
  - CatalogLayoutGuildForumView
  - CatalogGuildSelectorWidgetView
  - WiredSelectorUsersGroupView
  - WiredConditionActorIsGroupMemberView

Each fired its own request and re-listened independently. With four
of them mounted in the wired-tools panel during a builder session,
the same packet went out four times.

New useUserGroups() hook wraps the request/response pair with
useNitroQuery (queryKey ['nitro', 'user', 'groups'], staleTime
Infinity — guild membership is session-stable). All four consumers
now read 'const { data: groups = [] } = useUserGroups()' and React
Query dedups: one composer at the first mount, all subsequent mounts
get the cached array.

Drops 'groups' from ICatalogOptions and the corresponding listener +
prev-state-merge from useCatalog — no remaining consumer reads it.
2026-05-11 22:14:39 +02:00
simoleo89 0c43377f9a Drop dead 'await success' on fire-and-forget catalog-admin actions
CatalogAdminContext exposes savePage / deletePage / saveOffer /
createOffer / deleteOffer as void-returning fire-and-forget composer
dispatches — they just call SendMessageComposer and let the server
push back later. The Offer/Page edit views were 'await action(data);
if(success) closeForm()' as if the actions returned Promise<boolean>,
but they don't return anything. tsgo flagged the truthiness check on
void.

Drop the await + truthiness — call the action, then close the form
unconditionally. This matches the actual behaviour: closeForm() ran
synchronously after the void anyway. A future PR that wants real
'wait for server confirmation' UX should refactor the context to
return Promise<boolean> (correlated to the response packet via the
pendingActionRef machinery already in place).
2026-05-11 21:46:18 +02:00
simoleo89 a39aa37231 React 19: useRef<T>() -> useRef<T>(null) across 15 sites
React 19 dropped the no-arg useRef overload — the type-only useRef<T>()
form (no initial value) is gone, every call must pass an initial value.
The codebase had 15 occurrences of useRef<HTMLDivElement>() (DOM ref
pattern) all flagged by tsgo as 'Expected 1 arguments, but got 0'.

Mechanical sweep to useRef<HTMLDivElement>(null) — no behavior change,
React still hands out a ref object with .current set to null at mount.

Net tsgo error count: 57 -> 42.
2026-05-11 21:33:58 +02:00
simoleo89 8e4544c5aa Migrate catalog giftConfiguration to useNitroQuery
The catalog's gift wrapping configuration was loaded by an effect in
useCatalog that fired GetGiftWrappingConfigurationComposer every time
the catalog opened, with the response stuffed into a catalogOptions
slice via setState-in-effect. Migrating to a TanStack query gives us
caching/dedup/loading-state for free on this one-shot session-stable
loader.

- New useGiftConfiguration() hook in src/hooks/catalog/ wraps the
  composer/parser pair with useNitroQuery and staleTime: Infinity
  (the wrapping config never changes within a session).
- CatalogGiftView now reads from the query directly instead of via
  catalogOptions; the useCatalog() call in that component is also
  dropped (no other field was used).
- useCatalog drops the GiftWrappingConfigurationEvent listener and the
  unconditional composer dispatch.
- ICatalogOptions loses the giftConfiguration? field — no remaining
  consumer.

First step toward the docs/ARCHITECTURE.md next-PR item 'Migrate
useCatalog read-only fetches to useNitroQuery'. The clubGifts loader
will follow once useNitroEventInvalidator lands (clubGifts can be
push-updated by the server after SelectClubGiftComposer, so it needs
cache invalidation, not just a one-shot fetch).
2026-05-11 21:12:34 +02:00
simoleo89 388fb8ed34 Migrate CatalogLayoutRoomAdsView's room-ad fetch to useNitroQuery
Second concrete adoption of proposal #2 (first was OfferView).

Before
- A useState<RoomEntryData[]>([]) for availableRooms.
- A useMessageEvent<RoomAdPurchaseInfoEvent> handler that
  set the state on each parser event.
- A useEffect on mount that dispatched two composers, one of which
  was GetRoomAdPurchaseInfoComposer paired with the parser above.

After
- A single useNitroQuery call wires the request and parser as one
  read-only query. The select extracts parser.rooms with a default
  empty array.
- staleTime is 60s — opening the same panel within a minute reuses
  the cached value; the composer is not re-dispatched. Useful here
  because the user navigates between catalog tabs.
- The mount-only useEffect no longer dispatches the room-ad composer;
  the second composer (GetUserEventCatsMessageComposer) stays where
  it was — that one feeds useNavigator state and isn't a
  request-response pair this component owns.

Why this file
- It was the cleanest pattern in the catalog tree: no correlation
  keys, no conditional filter on the parser, no other writes to
  availableRooms. The pure derive-from-event case useNitroQuery is
  built for.
- The big god-hook useCatalog (1100 LOC) still owns most of the
  catalog data layer; migrating that needs the data/uiState/actions
  split first.

Verification
- yarn test: 49/49 still passing.
- yarn eslint on the touched file: 1 error (the pre-existing
  set-state-in-effect on line 36, unchanged — baseline matches).
- The previous useMessageEvent import was removed cleanly.
2026-05-11 16:40:43 +00:00
simoleo89 34b1b56788 Enable React Query (proposal #2) + first real-data pilot on OfferView
Phase 1 of the refactor plan in docs/ARCHITECTURE.md.

Install
- yarn add @tanstack/react-query@5 @tanstack/react-query-devtools@5
- Both pinned to ^5 (matches React 19 peer requirement).

Wiring
- src/index.tsx: mounts QueryClientProvider above ErrorBoundary +
  Suspense. Default config: staleTime=30s, retry=1,
  refetchOnWindowFocus=false (chat client, not a data dashboard).

Adapter
- src/api/nitro-query/createNitroQuery.ts: replaces the previous
  prototype that just threw. Exposes:
    * useNitroQuery({ key, request, parser, select, timeoutMs })
      — wraps TanStack's useQuery; queryFn awaits the parser response.
    * awaitNitroResponse(...) — lower-level helper for imperative use
      via queryClient.fetchQuery.
  The Promise:
    1. registers the parser via GetCommunication().registerMessageEvent
    2. dispatches the composer via SendMessageComposer
    3. resolves with select(event) on the first matching parser
    4. rejects after timeoutMs (default 15s)
    5. always cleans up the listener + timeout (cancel-safe).

Pilot
- src/components/catalog/views/targeted-offer/OfferView.tsx:
  the previous useMessageEventState + manual useEffect-send pattern
  becomes a single useNitroQuery call. staleTime:Infinity because the
  targeted offer doesn't change during a session. Subsequent OfferView
  remounts (e.g. opening/closing the dialog) now reuse the cached
  payload — the GetTargetedOfferComposer is no longer re-sent each
  time.

Verification
- yarn eslint on the four touched files: 1 pre-existing
  no-redundant-type-constituents error (IMessageEvent resolves as `any`
  in the local sandbox without the renderer SDK installed; matches the
  12 other pre-existing instances of the same false positive).
- yarn tsc on the four touched files: clean (modulo the
  project-wide TS2307 about @nitrots/nitro-renderer).
- The original prototype's "throw" guard is gone — useNitroQuery is now
  callable.

Migration path (per docs/ARCHITECTURE.md)
- Next adoption targets (read-only fetches first): useCatalog's page
  data, useInventoryFurni's bot listing, Navigator search results,
  Marketplace listings.
- Push messages (server-pushed events the client doesn't request)
  keep using useNitroEventState / useMessageEventState — they're
  subscriptions, not requests.

https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
2026-05-11 16:31:53 +00:00
simoleo89 22a44d18b0 Add useNitroEventState / useMessageEventState hooks (proposal #1)
Introduce the building block for reducing the state-from-event
boilerplate that pervades the codebase:

  // Before
  const [foo, setFoo] = useState(initial);
  useNitroEvent(SOME_EVENT, e => setFoo(e.payload));

  // After
  const foo = useNitroEventState(SOME_EVENT, e => e.payload, initial);

Implementation notes:
- src/hooks/events/useNitroEventState.ts wraps useNitroEvent so the
  selector closure can use up-to-date surrounding values (captured in
  a ref refreshed in commit via useLayoutEffect) without forcing a
  re-subscription on every render. Listener is registered once and
  always reads the latest selector.
- src/hooks/events/useMessageEventState.ts is the mirror for
  useMessageEvent (server message channel — request/response composers
  and push parsers).
- Both pass the new react-hooks v7 rules cleanly (in particular the
  strict react-hooks/refs that forbids ref mutation during render).
- Re-exported from src/hooks/events/index.ts so callers reach them
  via the existing `from '../../hooks'` import path.

Pilot adoption (1 site) to demonstrate the pattern:
- src/components/catalog/views/targeted-offer/OfferView.tsx:
  the offer state was a clean derive-from-event case
  (setOffer(parser.data) on TargetedOfferEvent, no other writes).
  Replaced with a single useMessageEventState call using the optional
  chain `evt.getParser()?.data ?? null` as selector. Removes the
  useState pair and the explicit subscription block.

Honest scope note:
A broader sweep is intentionally NOT done. Most existing event
subscriptions in this codebase are multi-state updates, state
machines, conditional filters ("skip if not my id"), or have side
effects mixed in (notifications, redirects). Forcing those into
useNitroEventState would lose information and risk regressions in
behavior the lint won't catch. Adoption should happen organically
when contributors see a clean derive-from-event case, not as a
mechanical replace-all.

https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
2026-05-11 16:31:52 +00:00
simoleo89 39eb2c6b84 Phase C (targeted): clear 4 set-state-in-effect violations on safe candidates
Fix only the cases that are unambiguous anti-patterns; leave the
event-driven setState patterns (useNitroEvent / useMessageEvent
subscriptions, async fetches with cleanup) alone since they're
legitimate in this architecture.

- src/components/catalog/views/catalog-header/CatalogHeaderView.tsx:
  displayImageUrl was pure-derived from imageUrl. Drop the useState +
  useEffect entirely; compute in render.
- src/components/navigator/views/NavigatorRoomCreatorView.tsx:
  the maxVisitors list (10..100 step 10) and roomModels/selectedModel
  came from static config; convert to module-level MAX_VISITORS_LIST
  constant + useState lazy initializers. Removes 2 init effects.
  setCategory(categories[0].id) is left as-is because categories
  arrives async from a hook.
- src/components/login/LoginView.tsx:
  Replace useEffect(() => setLocalError(null), [step]) with the
  React-recommended "track previous prop" render-time reset:
  if(prevStep !== step) { setPrevStep(step); setLocalError(null); }
  Same observable behavior, no extra render.
- src/components/room/widgets/choosers/ChooserWidgetView.tsx:
  Wrap the selectItem callback prop call in useEffectEvent so a
  parent re-render that changes selectItem identity doesn't
  re-fire the visualizer side-effects.

Net: 4 fewer set-state-in-effect violations; behavior preserved.
The remaining ~328 violations across the codebase are predominantly
legitimate event-bus / async-fetch patterns and need per-case
review with running app, not a sweep.

https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
2026-05-11 16:31:52 +00:00
simoleo89 535fa71020 ESLint --fix: auto-fix brace-style, indent, semi, no-trailing-spaces
Run eslint --fix across src/ to clear ~1900 mechanical lint errors
surfaced by the @typescript-eslint v8 + react-hooks v7 + react-compiler
upgrade in the React 19 modernization PR.

Issues fixed automatically:
- brace-style (Allman): try/catch one-liners reformatted to multi-line
- indent: tab-vs-space and depth corrections
- semi: missing trailing semicolons
- no-trailing-spaces

No semantic changes. Remaining 701 errors are real-code issues
(set-state-in-effect, rules-of-hooks, no-unsafe-* type checks) that
need manual per-file review.

https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
2026-05-11 16:31:50 +00:00
simoleo89 a1bee1d825 React 19 modernization: forwardRef removal, Compiler, ErrorBoundary, Suspense, native <script>
Adopt React 19 idioms across the codebase. The runtime was already on
react@19.2.5 but no React 19 APIs were in use.

- forwardRef -> ref-as-prop in 7 layout/component files
  (NitroInput/Button/ItemCountBadge/Card×5/InfiniteGridItem,
  ToolbarItemView, AvatarEditorIcon)
- <Ctx.Provider> -> <Ctx> in 6 contexts (CatalogAdmin, FloorplanEditor,
  UiSettings, GridContext, NitroCardContext, NitroCardAccordionContext)
- Native <script> hoisting for Turnstile, ExternalPluginLoader, GoogleAdsView
  (React 19 dedupes by src; removes manual document.head.appendChild +
  module-level promise caches)
- React Compiler enabled at build time via babel-plugin-react-compiler
  in vite.config.mjs (target: '19'), plus eslint-plugin-react-compiler
  in lint mode
- Global <ErrorBoundary> + <Suspense> in src/index.tsx using
  react-error-boundary, with LoadingView as fallback
- BackgroundsView migrated to use(promise) as a demonstrator pattern
  for Suspense-driven config loading
- ESLint react setting bumped 18.3.1 -> 19.2; legacy
  @typescript-eslint/ban-types replaced with no-restricted-types
  (the old rule was removed in @typescript-eslint v8)
- Refresh public/configuration/{asset-loader,bootstrap}.js to match
  current write-asset-loader.mjs output

Phase 3 (login forms -> useActionState/useFormStatus) deferred:
LoginView is 1623 lines with lockout + Turnstile + heartbeat
interleaving; safer as its own PR.

https://claude.ai/code/session_01GrR87LAqnAEyKG2ZbmQt5Q
2026-05-11 16:31:50 +00:00
Lorenzune 726d1cc5c8 Merge latest duckie main with login UI 2026-04-21 11:53:30 +02:00
Lorenzune 9b36513def WIP preserve local changes before duckie merge 2026-04-21 11:13:32 +02:00
duckietm 4eb4af3e65 🆙 Some text update 2026-04-17 14:51:26 +02:00
duckietm d3e6743fdf 🆙 Do not make have_offer static 2026-04-17 14:23:26 +02:00
duckietm 88117d937f 🆙 Fix Catalog editor 2026-04-17 13:51:46 +02:00
duckietm 9dbe360448 🆙 Fixed Buddy Pets showing in catalogue 2026-04-15 13:09:27 +02:00
Lorenzune f12b43ca92 Merge remote-tracking branch 'upstream/main' into merge/duckietm-main-20260407 2026-04-07 15:28:04 +02:00
Lorenzune 954e477e47 feat: add builders club catalog ui flow 2026-04-07 14:40:51 +02:00
DuckieTM 93b38316fa 🆙 Gifts 2026-04-05 09:42:00 +02:00
Lorenzune 60f0355a13 Merge remote-tracking branch 'upstream/main' 2026-04-03 12:04:39 +02:00
duckietm 9df5663103 🆙 Sync 2026-04-03 11:43:04 +02:00
duckietm 01de4b239e 🆙 Fix Scrollbar old view 2026-04-03 08:02:19 +02:00
Lorenzune 21193daebb Merge upstream/main into feature/checkpoint-20260403 2026-04-03 05:26:19 +02:00
Lorenzune 36c0221a54 chore: checkpoint current work 2026-04-03 05:22:26 +02:00
duckietm 6ad9a0c7b9 🆙 Fix the catalog search in some cases it will give a black screen 2026-04-01 17:12:09 +02:00
duckietm a4d4764105 🆙 Moved catalogue view to Tailwind 2026-03-27 10:08:02 +01:00
duckietm df1437c488 🆙 Cleanup 2026-03-24 11:56:51 +01:00
DuckieTM 4aef7d5f94 Merge branch 'main' into chat-prefix 2026-03-24 10:54:42 +01:00
DuckieTM 7ffb213ce7 ㊙️ Security Fixes
- XSS fix: Created SanitizeHtml.ts utility using DOMPurify (already in package.json but never used). Wrapped all 21 dangerouslySetInnerHTML calls in catalog views with SanitizeHtml() — only allows safe tags (b, i, u, br, span, div, p, a, strong, em, img)

- Race condition fix: Added 10-second timeout fallbacks on purchase flags in CatalogPurchaseWidgetView and CatalogGiftView so the flag auto-resets even if the server never responds
2026-03-23 22:14:03 +01:00
duckietm dc678cb7ff 💱 Changed the ui-config so please update ! 2026-03-23 16:49:44 +01:00
duckietm 33c31fe07d 🆕 Added New catalogue page 2026-03-23 15:02:20 +01:00
duckietm a0d0b5c4a4 Revert "Merge branch 'main' into furnisettingeditor-pr"
This reverts commit dfbfb1c2c1, reversing
changes made to 07702c44d0.
2026-03-23 11:49:05 +01:00
DuckieTM 735be2aff6 Merge branch 'main' into catalog-redesign 2026-03-23 08:49:20 +01:00
Life 02d8e5c2dd feat: custom chat prefix system with effects, gradient colors, emoji icons and per-letter coloring 2026-03-22 18:43:42 +01:00
Life a5ea88010e feat: catalog style toggle (classic/new) with admin mode & favorites 2026-03-22 16:54:40 +01:00
simoleo89 74dce1d55d feat(catalog): complete UI redesign with admin mode & favorites
- Modern card-based layout with vertical icon rail, breadcrumb nav, inline search
- Admin mode: edit/create/delete pages and offers, drag & drop reorder via HK API
- Favorites system: heart on furni, star on pages, localStorage persistence
- Redesigned product card with price pills, dynamic quantity spinner
- Upgraded trophies (filter tabs, parchment textarea), pets (breed/color flow),
  custom prefix (dynamic color boxes)
- Font fix: Ubuntu Regular, proper @font-face declarations
- New Tailwind design tokens and CatalogTexts.json for localization
2026-03-21 16:49:35 +01:00
simoleo89 11543bb64c feat: custom prefix system with effects, emoji picker and per-letter colors
- Catalog page for creating custom prefixes with text, per-letter colors, emoji icon and visual effects
- Emoji picker via @emoji-mart/react with createPortal + Shadow DOM blur fix
- Inventory prefix management (activate/deactivate/delete)
- Chat bubble rendering with multi-color prefix and effect support
- Prefix utilities (getPrefixEffectStyle, parsePrefixColors, PREFIX_EFFECT_KEYFRAMES)
- All UI text in English
2026-03-20 17:07:33 +01:00
duckietm bffaccf6a3 🆙 Security Fix - Will not go into details 2026-03-18 16:52:32 +01:00
duckietm 16dce04475 🐟 Fix catalog index and start on Groupbadges 2026-02-20 11:49:01 +01:00