The info tab needed a different shape than the prior 5 — its
panelInfo, promoGroups, editingPromoGroup, action handlers
(reset-trial, disable, full-delete) are SHARED with the subscription
tab and with the inline-confirm armer up in the parent. Moving any
of that into the tab would either duplicate the query/state or
fork the confirm state. So the new InfoTab is a 'view facade':
state and handlers stay parent-owned, the JSX moves out.
Prop surface is wide (~25 props) but every prop is a single
'pass through' line at the call site — no logic shifted. Each
handler / state slot still lives in exactly one place; the tab
only renders.
Bonus: the parent's formatWithCurrency / useCurrency import is
dropped — info tab was the last consumer in the page body.
Net to AdminUserDetail.tsx: down to 1992 lines (-403). Total
journey across the 6 tab extractions: 3820 → 1992 (-1828, -48%).
Only the subscription tab JSX (~1043 lines) remains inline.
Pulls the tickets tab into components/admin/userDetail/TicketsTab.tsx:
- ticketsQuery (list)
- selectedTicketId / selectedTicket state + detail loader
- reply form state (replyText, replySending)
- status-change action handler + actionLoading
- auto-scroll messagesEndRef
- JSX is split into ChatView + TicketsList + EmptyState helpers
inside the new file (file stays under 400 lines despite the
split-view UI)
Parent now passes only userId + formatDate. Imports for adminApi,
MessageMediaGrid, linkifyText, AdminTicket / AdminTicketDetail types
all drop from AdminUserDetail.tsx — tickets are the only consumers.
Net to AdminUserDetail.tsx: down to 2395 lines (-295). Same DOM,
same event handlers, same queryKey ['admin-user-tickets', userId].
Pulls the balance tab — current balance, add/subtract form, active
promo offer summary + deactivate, send-offer form, recent
transactions list — into components/admin/userDetail/BalanceTab.tsx.
Form state (balanceAmount, balanceDescription, offerDiscountPercent,
offerValidHours), mutation handlers (handleUpdateBalance,
handleDeactivateOffer, handleSendOffer), and the inline two-click
confirm armer all live local now.
Tab takes a small prop surface — user, userId, hasPermission,
onUserRefresh, formatDate — and owns its own actionLoading /
offerSending flags so the deactivate-offer confirm doesn't dim
buttons on other tabs.
Net to AdminUserDetail.tsx: down to 2690 lines (-212). The
promoOffersApi import is dropped from the parent — it's only the
balance tab that talks to that endpoint now. PlusIcon / MinusIcon
are still used by the subscription tab in the parent, so they stay
there too.
Pulls the entire referrals tab into components/admin/userDetail/
ReferralsTab.tsx — state, useQuery, debounced search effects,
click-outside listeners, mutation handlers, and the 295-line JSX
block all live there now. The tab owns its referralsListQuery and
calls onUserRefresh() (parent's loadUser) when a mutation needs to
re-fetch the user object.
Net to AdminUserDetail.tsx: down to 2902 lines (-479). Local
'actionLoading' inside the tab means referral-mutation clicks no
longer dim balance/sync buttons. Same DOM, same handlers, same
queryKey ['admin-user-referrals-list', userId].
AdminUserDetail.tsx was 3820 lines — a single page bigger than the
project's 800-line soft cap by 4.7x. Split the two most self-contained
tabs (no mutations / setState from within the tab body) into their
own files under src/components/admin/userDetail/:
GiftsTab.tsx (299 lines)
- GiftStatusBadge (badge for one of 6 lifecycle states)
- GiftCard (single row, sent or received)
- GiftsTab (sent + received sections + counters)
props: giftsLoading, giftsData, locale, onNavigateToUser
SyncTab.tsx (207 lines)
- ArrowDownIcon, ArrowUpIcon (used only by sync action buttons)
- SyncTab (bot vs panel comparison + 2-way sync)
props: user, syncStatus, userSubscriptions, activeSubscriptionId,
onActiveSubscriptionChange, actionLoading, onSyncFromPanel,
onSyncToPanel, locale
Net: AdminUserDetail.tsx down to 3381 lines (-439). No behaviour change:
the tabs render the exact same DOM, same SVG paths, same event handlers
just routed through props.