i18n(mod-tools): route every label/title/placeholder through LocalizeText

The ModTools template refresh introduced ~80 hardcoded English strings
(labels, placeholders, tooltips, empty-state copy, button text). Move
every one of them onto the modtools.* namespace and read via
LocalizeText so the panels translate alongside the rest of the client.

UITexts.example (versioned template) extended with the full set:

  modtools.window.*            Launcher box (toolbar item, tools,
                               selected-user state, ticket count)
  modtools.userinfo.*          User info card — already had the
                               legacy modtools.userinfo.{userName,
                               cfhCount, …} keys from before; added
                               refresh tooltip, presence pill labels
                               (in_room / online / offline with
                               matching .title tooltips), section
                               headings, action button labels, stat
                               card labels
  modtools.roominfo.*          Room info card — title, refresh, loading,
                               owner pill (here/away + tooltips), stat
                               labels, action buttons, moderate panel
                               heading + checkboxes + textarea
                               placeholder + caution/alert CTAs
  modtools.user.message.*      Send-message dialog (recipient label,
                               body label, placeholder, char counter,
                               empty state, send button)
  modtools.user.modaction.*    Mod Action form — header, sanctioning
                               label, 3-step section titles, select
                               placeholders, message label + optional
                               note, message placeholder, preview
                               heading, default/apply buttons, every
                               sendAlert error message
  modtools.user.visits.*       Room visits — title, header strip
                               heading, entry count (singular/plural),
                               empty state, column headers, visit
                               button + tooltip
  modtools.user.chatlog.*      User chatlog — title (with username
                               variant), loading state
  modtools.room.chatlog.*      Room chatlog title
  modtools.chatlog.*           Shared ChatlogView — column headers,
                               empty state, room-separator Visit/Tools
                               buttons
  modtools.tickets.*           Tickets window — title, tab labels
                               (open/mine/picked), column headers,
                               empty states, action buttons (pick/
                               handle/release), issue resolution
                               window (title, label, details heading,
                               field labels, chatlog toggle, resolve-as
                               heading, resolution buttons, release
                               back to queue), CFH chatlog title

The same 130 entries land in Nitro-Files/.../UITexts.json (runtime).
Both files validate as JSON. The runtime additions take effect on
next client reload; the template additions ship the strings to any
fresh deploy.

Notes:
  - The MOD_ACTION_DEFINITIONS sanction names ("Alert", "Mute 1h",
    "Ban 18h" …) stay hardcoded for now since they're keyed off
    server-side action IDs that don't have an existing locale key
    convention. Worth a follow-up if needed.
  - help.cfh.topic.* keys (CFH topic display names) are already in
    ExternalTexts.json and were already read via LocalizeText, so
    they didn't need changes.

typecheck + vitest 214/214 + lint:hooks all clean.
This commit is contained in:
simoleo89
2026-05-20 21:02:22 +02:00
committed by simoleo89
parent d3552a0948
commit 75815fa022
16 changed files with 291 additions and 139 deletions
@@ -1,7 +1,7 @@
import { ChatRecordData, GetUserChatlogMessageComposer, UserChatlogEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaSpinner } from 'react-icons/fa';
import { SendMessageComposer } from '../../../../api';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
import { ChatlogView } from '../chatlog/ChatlogView';
@@ -35,13 +35,13 @@ export const ModToolsUserChatlogView: FC<ModToolsUserChatlogViewProps> = props =
return (
<NitroCardView className="nitro-mod-tools-chatlog min-w-[460px] max-w-[520px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ username ? `User Chatlog: ${ username }` : 'User Chatlog' } onCloseClick={ onCloseClick } />
<NitroCardHeaderView headerText={ username ? LocalizeText('modtools.user.chatlog.title.with', [ 'username' ], [ username ]) : LocalizeText('modtools.user.chatlog.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black h-full" gap={ 1 }>
{ userChatlog
? <ChatlogView records={ userChatlog } />
: <div className="flex flex-col items-center justify-center gap-2 py-8 opacity-50 text-sm">
<FaSpinner className="animate-spin" size={ 22 } />
<span>Loading chatlog</span>
<span>{ LocalizeText('modtools.user.chatlog.loading') }</span>
</div> }
</NitroCardContentView>
</NitroCardView>
@@ -77,7 +77,7 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
const category = topics[selectedTopic];
if(selectedTopic === -1) return sendAlert('You must select a CFH topic');
if(selectedTopic === -1) return sendAlert(LocalizeText('modtools.user.modaction.error.no.topic'));
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
@@ -94,10 +94,10 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
const category = topics[selectedTopic];
const sanction = MOD_ACTION_DEFINITIONS[selectedAction];
if((selectedTopic === -1) || (selectedAction === -1)) errorMessage = 'You must select a CFH topic and Sanction';
else if(!settings || !settings.cfhPermission) errorMessage = 'You do not have permission to do this';
else if(!category) errorMessage = 'You must select a CFH topic';
else if(!sanction) errorMessage = 'You must select a sanction';
if((selectedTopic === -1) || (selectedAction === -1)) errorMessage = LocalizeText('modtools.user.modaction.error.no.action');
else if(!settings || !settings.cfhPermission) errorMessage = LocalizeText('modtools.user.modaction.error.no.permission');
else if(!category) errorMessage = LocalizeText('modtools.user.modaction.error.no.topic');
else if(!sanction) errorMessage = LocalizeText('modtools.user.modaction.error.no.action');
if(errorMessage) return sendAlert(errorMessage);
@@ -106,7 +106,7 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
switch(sanction.actionType)
{
case ModActionDefinition.ALERT: {
if(!settings.alertPermission) return sendAlert('You have insufficient permissions');
if(!settings.alertPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
SendMessageComposer(new ModAlertMessageComposer(user.userId, messageOrDefault, category.id));
break;
}
@@ -114,12 +114,12 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
SendMessageComposer(new ModMuteMessageComposer(user.userId, messageOrDefault, category.id));
break;
case ModActionDefinition.BAN: {
if(!settings.banPermission) return sendAlert('You have insufficient permissions');
if(!settings.banPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
SendMessageComposer(new ModBanMessageComposer(user.userId, messageOrDefault, category.id, selectedAction, (sanction.actionId === 106)));
break;
}
case ModActionDefinition.KICK: {
if(!settings.kickPermission) return sendAlert('You have insufficient permissions');
if(!settings.kickPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert'));
SendMessageComposer(new ModKickMessageComposer(user.userId, messageOrDefault, category.id));
break;
}
@@ -129,7 +129,7 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
break;
}
case ModActionDefinition.MESSAGE: {
if(message.trim().length === 0) return sendAlert('Please write a message to user');
if(message.trim().length === 0) return sendAlert(LocalizeText('modtools.user.modaction.error.no.message'));
SendMessageComposer(new ModMessageMessageComposer(user.userId, message, category.id));
break;
}
@@ -149,41 +149,43 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
return (
<NitroCardView className="nitro-mod-tools-user-action min-w-[420px] max-w-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ `Mod Action: ${ user.username }` } onCloseClick={ () => onCloseClick() } />
<NitroCardHeaderView headerText={ LocalizeText('modtools.user.modaction.title', [ 'username' ], [ user.username ]) } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black" gap={ 2 }>
{/* Target header */}
<div className="flex items-center gap-2 bg-gradient-to-r from-rose-50 to-transparent rounded p-2 border border-rose-100">
<FaGavel className="text-rose-600 shrink-0" size={ 16 } />
<div className="flex flex-col grow min-w-0">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Sanctioning</div>
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.modaction.sanctioning') }</div>
<div className="font-semibold leading-tight truncate">{ user.username }</div>
</div>
</div>
{/* CFH topic */}
<div className="flex flex-col gap-1">
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">1. CFH Topic</label>
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.modaction.step.topic') }</label>
<select className="form-select form-select-sm" value={ selectedTopic } onChange={ event => setSelectedTopic(parseInt(event.target.value)) }>
<option disabled value={ -1 }>Select a topic</option>
<option disabled value={ -1 }>{ LocalizeText('modtools.user.modaction.step.topic.placeholder') }</option>
{ topics.map((topic, index) => <option key={ index } value={ index }>{ LocalizeText('help.cfh.topic.' + topic.id) }</option>) }
</select>
</div>
{/* Sanction type */}
<div className="flex flex-col gap-1">
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">2. Sanction</label>
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.modaction.step.sanction') }</label>
<select className="form-select form-select-sm" value={ selectedAction } onChange={ event => setSelectedAction(parseInt(event.target.value)) }>
<option disabled value={ -1 }>Select a sanction</option>
<option disabled value={ -1 }>{ LocalizeText('modtools.user.modaction.step.sanction.placeholder') }</option>
{ MOD_ACTION_DEFINITIONS.map((action, index) => <option key={ index } value={ index }>{ action.name }</option>) }
</select>
</div>
{/* Message */}
<div className="flex flex-col gap-1">
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">3. Custom message <span className="opacity-50 normal-case font-normal">(optional overrides default)</span></label>
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">
{ LocalizeText('modtools.user.modaction.step.message') } <span className="opacity-50 normal-case font-normal">{ LocalizeText('modtools.user.modaction.step.message.optional') }</span>
</label>
<textarea
className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-rose-300"
placeholder="Leave empty to use the default topic message"
placeholder={ LocalizeText('modtools.user.modaction.message.placeholder') }
value={ message }
onChange={ event => setMessage(event.target.value) }
/>
@@ -192,7 +194,7 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
{/* Preview */}
{ (selectedSanction || selectedTopicName) &&
<div className="flex flex-col gap-1 bg-zinc-50 border border-zinc-200 rounded p-2">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Preview</div>
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.modaction.preview') }</div>
<div className="flex items-center gap-2 flex-wrap">
{ selectedTopicName &&
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-zinc-200">
@@ -208,10 +210,10 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
{/* Action buttons */}
<div className="flex gap-1.5 pt-1 border-t border-zinc-200">
<Button className="grow" disabled={ !canSubmit } gap={ 1 } variant="primary" onClick={ sendDefaultSanction }>
<FaBolt size={ 12 } /> Default Sanction
<FaBolt size={ 12 } /> { LocalizeText('modtools.user.modaction.button.default') }
</Button>
<Button className="grow" disabled={ !canSubmit || selectedAction === -1 } gap={ 1 } variant="success" onClick={ sendSanction }>
<FaGavel size={ 12 } /> Apply Sanction
<FaGavel size={ 12 } /> { LocalizeText('modtools.user.modaction.button.apply') }
</Button>
</div>
</NitroCardContentView>
@@ -1,7 +1,7 @@
import { GetRoomVisitsMessageComposer, RoomVisitsData, RoomVisitsEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaClock, FaDoorOpen, FaSignInAlt } from 'react-icons/fa';
import { SendMessageComposer, TryVisitRoom } from '../../../../api';
import { LocalizeText, SendMessageComposer, TryVisitRoom } from '../../../../api';
import { DraggableWindowPosition, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
@@ -35,31 +35,35 @@ export const ModToolsUserRoomVisitsView: FC<ModToolsUserRoomVisitsViewProps> = p
const rows = roomVisitData?.rooms ?? [];
const isEmpty = rows.length === 0;
const countLabel = rows.length === 1
? LocalizeText('modtools.user.visits.entries.one', [ 'count' ], [ rows.length.toString() ])
: LocalizeText('modtools.user.visits.entries.many', [ 'count' ], [ rows.length.toString() ]);
return (
<NitroCardView className="nitro-mod-tools-user-visits min-w-[400px] max-w-[460px] max-h-[460px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'User Visits' } onCloseClick={ onCloseClick } />
<NitroCardHeaderView headerText={ LocalizeText('modtools.user.visits.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" gap={ 1 }>
{/* Header strip */}
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<FaDoorOpen className="text-sky-600 shrink-0" size={ 14 } />
<div className="text-sm font-semibold leading-tight grow">Recent visited rooms</div>
<div className="text-sm font-semibold leading-tight grow">{ LocalizeText('modtools.user.visits.recent') }</div>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border bg-white border-zinc-200">
{ rows.length } { rows.length === 1 ? 'entry' : 'entries' }
{ countLabel }
</span>
</div>
{/* Table head */}
<div className="grid grid-cols-[60px_1fr_80px] gap-2 text-[.7rem] uppercase tracking-wide opacity-60 font-semibold border-b border-zinc-200 pb-1 px-1">
<div className="flex items-center gap-1"><FaClock size={ 10 } /> Time</div>
<div>Room name</div>
<div className="text-right">Action</div>
<div className="flex items-center gap-1"><FaClock size={ 10 } /> { LocalizeText('modtools.user.visits.time') }</div>
<div>{ LocalizeText('modtools.user.visits.room') }</div>
<div className="text-right">{ LocalizeText('modtools.user.visits.action') }</div>
</div>
{/* Rows */}
{ isEmpty
? <div className="flex flex-col items-center justify-center gap-1 py-6 opacity-50 text-sm">
<FaDoorOpen size={ 22 } />
<span>No recent visits</span>
<span>{ LocalizeText('modtools.user.visits.empty') }</span>
</div>
: <div className="flex flex-col grow min-h-0 overflow-hidden">
<InfiniteScroll rowRender={ row => (
@@ -71,8 +75,8 @@ export const ModToolsUserRoomVisitsView: FC<ModToolsUserRoomVisitsViewProps> = p
<button
className="inline-flex items-center justify-end gap-1 text-sky-700 hover:text-sky-900 hover:underline text-xs"
onClick={ () => TryVisitRoom(row.roomId) }
title="Visit room">
<FaSignInAlt size={ 10 } /> Visit
title={ LocalizeText('modtools.user.visits.visit.title') }>
<FaSignInAlt size={ 10 } /> { LocalizeText('modtools.user.visits.visit') }
</button>
</div>
) } rows={ rows } />
@@ -1,7 +1,7 @@
import { ModMessageMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FaEnvelope, FaPaperPlane, FaUser } from 'react-icons/fa';
import { ISelectedUser, SendMessageComposer } from '../../../../api';
import { ISelectedUser, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useNotification } from '../../../../hooks';
@@ -36,13 +36,13 @@ export const ModToolsUserSendMessageView: FC<ModToolsUserSendMessageViewProps> =
return (
<NitroCardView className="nitro-mod-tools-user-message min-w-[360px] max-w-[420px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'Send Message' } onCloseClick={ () => onCloseClick() } />
<NitroCardHeaderView headerText={ LocalizeText('modtools.user.message.title') } onCloseClick={ () => onCloseClick() } />
<NitroCardContentView className="text-black" gap={ 2 }>
{/* Recipient header */}
<div className="flex items-center gap-2 bg-gradient-to-r from-sky-50 to-transparent rounded p-2 border border-sky-100">
<FaEnvelope className="text-sky-600 shrink-0" size={ 16 } />
<div className="flex flex-col grow min-w-0">
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Message to</div>
<div className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.message.recipient') }</div>
<div className="flex items-center gap-1.5 font-semibold leading-tight truncate">
<FaUser className="opacity-60" size={ 11 } />
<span className="truncate">{ user.username }</span>
@@ -52,21 +52,21 @@ export const ModToolsUserSendMessageView: FC<ModToolsUserSendMessageViewProps> =
{/* Body */}
<div className="flex flex-col gap-1">
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">Message</label>
<label className="text-[.7rem] uppercase tracking-wide opacity-60 font-semibold">{ LocalizeText('modtools.user.message.label') }</label>
<textarea
autoFocus
className="min-h-[100px] px-2 py-1.5 rounded text-sm border border-zinc-300 focus:outline-none focus:ring-2 focus:ring-sky-300"
placeholder="Write something useful — the user will see it as a moderator message."
placeholder={ LocalizeText('modtools.user.message.placeholder') }
value={ message }
onChange={ event => setMessage(event.target.value) }
/>
<div className="flex justify-between text-xs opacity-60">
<span>{ canSend ? `${ trimmed.length } chars` : 'Empty' }</span>
<span>{ canSend ? LocalizeText('modtools.user.message.chars', [ 'count' ], [ trimmed.length.toString() ]) : LocalizeText('modtools.user.message.empty') }</span>
</div>
</div>
<Button disabled={ !canSend } fullWidth gap={ 1 } variant="primary" onClick={ sendMessage }>
<FaPaperPlane size={ 12 } /> Send Message
<FaPaperPlane size={ 12 } /> { LocalizeText('modtools.user.message.send') }
</Button>
</NitroCardContentView>
</NitroCardView>
@@ -76,7 +76,12 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
[ roomUserList, userId ]
);
const isOnline = isPresentInCurrentRoom || !!(userInfo && userInfo.online);
const presenceLabel = isPresentInCurrentRoom ? 'In room' : (isOnline ? 'Online' : 'Offline');
const presenceLabel = isPresentInCurrentRoom
? LocalizeText('modtools.userinfo.presence.in_room')
: (isOnline ? LocalizeText('modtools.userinfo.presence.online') : LocalizeText('modtools.userinfo.presence.offline'));
const presenceTitle = isPresentInCurrentRoom
? LocalizeText('modtools.userinfo.presence.in_room.title')
: (isOnline ? LocalizeText('modtools.userinfo.presence.online.title') : LocalizeText('modtools.userinfo.presence.offline.title'));
const presencePillClass = isPresentInCurrentRoom
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
: isOnline
@@ -132,60 +137,60 @@ export const ModToolsUserView: FC<ModToolsUserViewProps> = props =>
</div>
<span
className={ `inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium border ${ presencePillClass }` }
title={ isPresentInCurrentRoom ? 'In the room you are observing' : (isOnline ? 'Online on the hotel' : 'Offline at panel open') }>
title={ presenceTitle }>
<span className={ `inline-block w-2 h-2 rounded-full ${ presenceDotClass }` } />
{ presenceLabel }
</span>
<button
className="inline-flex items-center justify-center w-7 h-7 rounded text-zinc-500 hover:text-sky-700 hover:bg-sky-100 transition-colors shrink-0"
onClick={ refresh }
title="Refresh user info">
title={ LocalizeText('modtools.userinfo.refresh') }>
<FaSync size={ 12 } />
</button>
</div>
{/* Moderation stat strip */}
<div className="flex gap-1.5">
<StatCard icon={ <FaExclamationTriangle size={ 10 } /> } label="CFH" tone="warn" value={ userInfo.cfhCount } />
<StatCard icon={ <FaGavel size={ 10 } /> } label="Cautions" tone="warn" value={ userInfo.cautionCount } />
<StatCard icon={ <FaBan size={ 10 } /> } label="Bans" tone="danger" value={ userInfo.banCount } />
<StatCard icon={ <FaExchangeAlt size={ 10 } /> } label="Trade locks" tone="danger" value={ userInfo.tradingLockCount } />
<StatCard icon={ <FaExclamationTriangle size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.cfh') } tone="warn" value={ userInfo.cfhCount } />
<StatCard icon={ <FaGavel size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.cautions') } tone="warn" value={ userInfo.cautionCount } />
<StatCard icon={ <FaBan size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.bans') } tone="danger" value={ userInfo.banCount } />
<StatCard icon={ <FaExchangeAlt size={ 10 } /> } label={ LocalizeText('modtools.userinfo.stat.trade.locks') } tone="danger" value={ userInfo.tradingLockCount } />
</div>
{/* Body sections */}
<div className="flex flex-col gap-2 max-h-[300px] overflow-auto pr-1">
<Section title="Account">
<Field label="Email" value={ userInfo.primaryEmailAddress } />
<Field label="Registered" value={ FriendlyTime.format(userInfo.registrationAgeInMinutes * 60, '.ago', 2) } />
<Field label="Classification" value={ userInfo.userClassification } />
<Section title={ LocalizeText('modtools.userinfo.section.account') }>
<Field label={ LocalizeText('modtools.userinfo.primaryEmailAddress') } value={ userInfo.primaryEmailAddress } />
<Field label={ LocalizeText('modtools.userinfo.registrationAgeInMinutes') } value={ FriendlyTime.format(userInfo.registrationAgeInMinutes * 60, '.ago', 2) } />
<Field label={ LocalizeText('modtools.userinfo.userClassification') } value={ userInfo.userClassification } />
</Section>
<Section title="Activity">
<Field label="Last login" value={ FriendlyTime.format(userInfo.minutesSinceLastLogin * 60, '.ago', 2) } />
<Field label="Last purchase" value={ userInfo.lastPurchaseDate } />
<Section title={ LocalizeText('modtools.userinfo.section.activity') }>
<Field label={ LocalizeText('modtools.userinfo.minutesSinceLastLogin') } value={ FriendlyTime.format(userInfo.minutesSinceLastLogin * 60, '.ago', 2) } />
<Field label={ LocalizeText('modtools.userinfo.lastPurchaseDate') } value={ userInfo.lastPurchaseDate } />
</Section>
<Section title="Sanctions">
<Field label="Abusive CFH" value={ userInfo.abusiveCfhCount } />
<Field label="Last sanction" value={ userInfo.lastSanctionTime } />
<Field label="Identity bans" value={ userInfo.identityRelatedBanCount } />
<Section title={ LocalizeText('modtools.userinfo.section.sanctions') }>
<Field label={ LocalizeText('modtools.userinfo.abusiveCfhCount') } value={ userInfo.abusiveCfhCount } />
<Field label={ LocalizeText('modtools.userinfo.lastSanctionTime') } value={ userInfo.lastSanctionTime } />
<Field label={ LocalizeText('modtools.userinfo.identityRelatedBanCount') } value={ userInfo.identityRelatedBanCount } />
</Section>
<Section title="Trading">
<Field label="Lock expires" value={ userInfo.tradingExpiryDate } />
<Section title={ LocalizeText('modtools.userinfo.section.trading') }>
<Field label={ LocalizeText('modtools.userinfo.tradingExpiryDate') } value={ userInfo.tradingExpiryDate } />
</Section>
</div>
{/* Action bar */}
<div className="grid grid-cols-2 gap-1.5 pt-1 border-t border-zinc-200">
<Button gap={ 1 } variant="secondary" onClick={ () => CreateLinkEvent(`mod-tools/open-user-chatlog/${ userId }`) }>
<FaCommentDots size={ 12 } /> Room Chat
<FaCommentDots size={ 12 } /> { LocalizeText('modtools.userinfo.button.room.chat') }
</Button>
<Button gap={ 1 } variant="secondary" onClick={ () => setSendMessageVisible(prev => !prev) }>
<FaEnvelope size={ 12 } /> Send Message
<FaEnvelope size={ 12 } /> { LocalizeText('modtools.userinfo.button.send.message') }
</Button>
<Button gap={ 1 } variant="secondary" onClick={ () => setRoomVisitsVisible(prev => !prev) }>
<FaDoorOpen size={ 12 } /> Room Visits
<FaDoorOpen size={ 12 } /> { LocalizeText('modtools.userinfo.button.room.visits') }
</Button>
<Button gap={ 1 } variant="danger" onClick={ () => setModActionVisible(prev => !prev) }>
<FaGavel size={ 12 } /> Mod Action
<FaGavel size={ 12 } /> { LocalizeText('modtools.userinfo.button.mod.action') }
</Button>
</div>
</NitroCardContentView>