Merge latest duckie main with login UI

This commit is contained in:
Lorenzune
2026-04-21 11:53:30 +02:00
39 changed files with 1724 additions and 226 deletions
+110 -54
View File
@@ -1,57 +1,113 @@
{ {
"friendlist.search": "Zoek vrienden", "friendlist.search": "Search friends",
"widget.chooser.checkall": "Selecteer meubels", "purse.seasonal.currency.101": "cash",
"widget.chooser.btn.pickall": "pak de geselecteerde items op!", "widget.chooser.checkall": "Select furniture",
"widget.settings.general": "Standaard", "widget.chooser.btn.pickall": "pick up selected items!",
"widget.settings.general.title": "Pas de standaard nitro settings aan", "wiredfurni.params.requireall.2": "If one of the selected furni has an avatar",
"wiredfurni.params.requireall.3": "If all selected furni have avatars on them",
"widget.settings.general": "General",
"widget.settings.general.title": "Adjust the default Nitro settings",
"widget.settings.volume": "Volume", "widget.settings.volume": "Volume",
"widget.settings.interface": "Interface", "widget.settings.interface": "Interface",
"widget.settings.interface.title": "Pas de settings aan voor de interface", "widget.settings.interface.title": "Adjust the interface settings",
"widget.settings.interface.fps.automatic": "Zet FPS naar unlimited", "widget.settings.interface.fps.automatic": "Set FPS to unlimited",
"widget.settings.interface.fps.warning": "Het zetten van FPS naar unlimited kan prestatie problemen veroorzaken!", "widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!",
"widget.settings.interface.secondary": "Verander de window header kleur", "widget.settings.interface.secondary": "Change the window header color",
"widget.settings.interface.reset": "Reset header kleur naar default", "widget.settings.interface.reset": "Reset header color to default",
"widget.room.chat.hide_pets": "Verberg dieren", "widget.room.chat.hide_pets": "Hide pets",
"widget.room.chat.hide_avatars": "Verberg avatars", "widget.room.chat.hide_avatars": "Hide avatars",
"widget.room.chat.hide_balloon": "Verberg Spreekballon", "widget.room.chat.hide_balloon": "Hide speech bubble",
"widget.room.chat.show_balloon": "Spreekballon", "widget.room.chat.show_balloon": "Speech bubble",
"widget.room.chat.clear_history": "leeg geschiedenis", "widget.room.chat.clear_history": "clear history",
"widget.room.youtube.shared": "YouTube word gedeeld", "widget.room.youtube.shared": "YouTube is being shared",
"widget.room.youtube.open_video": "Open de video", "widget.room.youtube.open_video": "Open the video",
"widget.memenu.dance1": "Ballo 1", "wiredfurni.tooltip.select.tile": "Select tile",
"widget.memenu.dance2": "Ballo 2", "wiredfurni.tooltip.remove.tile": "Deselect tile",
"widget.memenu.dance3": "Ballo 3", "wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles",
"widget.memenu.dance4": "Ballo 4", "wiredfurni.tooltip.remove.clear_tile": "Clear all selections",
"wiredfurni.params.action.sign.0": "Cartello 0", "wiredfurni.params.furni_neighborhood.group.user": "Players",
"wiredfurni.params.action.sign.1": "Cartello 1", "wiredfurni.params.furni_neighborhood.group.furni": "Furniture",
"wiredfurni.params.action.sign.2": "Cartello 2", "wiredfurni.params.selector_option.bot": "No bots",
"wiredfurni.params.action.sign.3": "Cartello 3", "wiredfurni.params.selector_option.pet": "No pets",
"wiredfurni.params.action.sign.4": "Cartello 4", "catalog.title": "Catalog",
"wiredfurni.params.action.sign.5": "Cartello 5", "catalog.favorites": "Favorites",
"wiredfurni.params.action.sign.6": "Cartello 6", "catalog.favorites.pages": "Pages",
"wiredfurni.params.action.sign.7": "Cartello 7", "catalog.favorites.furni": "Furni",
"wiredfurni.params.action.sign.8": "Cartello 8", "catalog.favorites.empty": "No favorites",
"wiredfurni.params.action.sign.9": "Cartello 9", "catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.",
"wiredfurni.params.action.sign.10": "Cartello 10", "catalog.admin": "Admin",
"wiredfurni.params.action.sign.11": "Cartello 11", "catalog.admin.new": "New",
"wiredfurni.params.action.sign.12": "Cartello 12", "catalog.admin.root": "Root",
"wiredfurni.params.action.sign.13": "Cartello 13", "catalog.admin.new.root.category": "New root category",
"wiredfurni.params.action.sign.14": "Cartello 14", "catalog.admin.edit.root": "Edit Root",
"wiredfurni.params.action.sign.15": "Cartello 15", "catalog.admin.edit": "Edit:",
"wiredfurni.params.action.sign.16": "Cartello 16", "catalog.admin.edit.page": "Edit Page",
"wiredfurni.params.action.sign.17": "Cartello 17", "catalog.admin.hidden": "hidden",
"groupforum.list.tab.most_active": "Meest active threads", "catalog.admin.edit.title": "Edit \"%name%\"",
"groupforum.list.tab.my_forums": "Mijn group forums", "catalog.admin.show": "Show",
"groupforum.list.no_forums": "Er zijn geen forums", "catalog.admin.hide": "Hide",
"groupforum.view.threads": "Aantal threads", "catalog.admin.delete": "Delete",
"groupforum.thread.pin": "Pin hem vast", "catalog.admin.delete.title": "Delete \"%name%\"",
"groupforum.thread.unpin": "Unpin bericht", "catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?",
"groupforum.thread.lock": "Lock de thread", "catalog.admin.delete.page": "Delete page",
"groupforum.thread.unlock": "Unlock de thread", "catalog.admin.delete.page.confirm": "Delete page \"%name%\"?",
"groupforum.thread.hide": "Verberg thread", "catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?",
"groupforum.thread.restore": "Maak thread weer zichtbaar", "catalog.admin.create": "Create",
"groupforum.thread.delete": "Verwijder thread + posts", "catalog.admin.save": "Save",
"groupforum.message.hide": "Verberg bericht", "catalog.admin.create.subpage": "Create sub-page",
"group.forum.enable.caption": "Enable / Disable Group forum", "catalog.admin.order": "Order",
"group.forum.enable.help": "Als je de group forum disabled dan verwijderen ook alle posts!" "catalog.admin.visible": "Visible",
} "catalog.admin.enabled": "Enabled",
"catalog.admin.offer.new": "New Offer",
"catalog.admin.offer.edit": "Edit Offer",
"catalog.admin.offer.name": "Catalog Name",
"catalog.admin.offer.general": "General",
"catalog.admin.offer.quantity": "Quantity",
"catalog.admin.offer.prices": "Prices",
"catalog.admin.offer.credits": "Credits",
"catalog.admin.offer.points": "Points",
"catalog.admin.offer.points.type": "Points Type",
"catalog.admin.offer.options": "Options",
"catalog.admin.offer.club.only": "Club Only",
"catalog.admin.offer.extradata": "Extra Data (optional)....",
"catalog.admin.offer.have.offer": "Multi-discount (have_offer)",
"catalog.trophies.title": "Trophies",
"catalog.trophies.write.hint": "Write a text for the trophy before purchasing",
"catalog.trophies.inscription": "Trophy Inscription",
"catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...",
"catalog.pets.show.colors": "Show colors",
"catalog.pets.choose.color": "Choose color",
"catalog.pets.choose.breed": "Choose breed",
"catalog.pets.back.breeds": "← Breeds",
"catalog.prefix.text": "Text",
"catalog.prefix.text.placeholder": "Enter text...",
"catalog.prefix.icon": "Icon",
"catalog.prefix.icon.remove": "Remove icon",
"catalog.prefix.effect": "Effect",
"catalog.prefix.color": "Color",
"catalog.prefix.color.single": "🎨 Single",
"catalog.prefix.color.per.letter": "🌈 Per Letter",
"catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.",
"catalog.prefix.color.apply.all.title": "Apply current color to all letters",
"catalog.prefix.color.apply.all": "Apply to all",
"catalog.prefix.color.selected": "Selected letter:",
"catalog.prefix.price": "Price:",
"catalog.prefix.price.amount": "5 Credits",
"catalog.prefix.purchased": "✓ Purchased!",
"catalog.prefix.purchase": "Purchase",
"groupforum.list.tab.most_active": "Most active threads",
"groupforum.list.tab.my_forums": "My group forums",
"groupforum.list.no_forums": "There are no forums",
"groupforum.view.threads": "Number of threads",
"groupforum.thread.pin": "Pin thread",
"groupforum.thread.unpin": "Unpin thread",
"groupforum.thread.lock": "Lock thread",
"groupforum.thread.unlock": "Unlock thread",
"groupforum.thread.hide": "Hide thread",
"groupforum.thread.restore": "Restore thread",
"groupforum.thread.delete": "Delete thread + posts",
"groupforum.message.hide": "Hide message",
"group.forum.enable.caption": "Enable / Disable group forum",
"group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!",
"groupforum.view.no_threads": "There are currently no active threads"
}
+1
View File
@@ -0,0 +1 @@
google.com, ## YOUR pub-XXXXXXXXX, DIRECT, XXXXXXX
+5
View File
@@ -0,0 +1,5 @@
{
"slot": "### SLOT ID FROM GOOGLE - data-ad-slot ###",
"format": "auto",
"fullWidthResponsive": true
}
+9 -1
View File
@@ -1,5 +1,6 @@
{ {
"socket.url": "ws://192.168.1.52:2096", "socket.url": "wss://nitro.slogga.it:2096",
"api.url": "https://nitro.slogga.it:2096",
"asset.url": "https://client.slogga.it/nitro/bundled", "asset.url": "https://client.slogga.it/nitro/bundled",
"image.library.url": "https://client.slogga.it/c_images/", "image.library.url": "https://client.slogga.it/c_images/",
"hof.furni.url": "https://client.slogga.it/c_images/dcr/hof_furni", "hof.furni.url": "https://client.slogga.it/c_images/dcr/hof_furni",
@@ -41,6 +42,13 @@
"room.color.skip.transition": true, "room.color.skip.transition": true,
"room.landscapes.enabled": true, "room.landscapes.enabled": true,
"room.zoom.enabled": true, "room.zoom.enabled": true,
"login.screen.enabled": true,
"login.endpoint": "${api.url}/api/auth/login",
"login.register.endpoint": "${api.url}/api/auth/register",
"login.forgot.endpoint": "${api.url}/api/auth/forgot-password",
"login.logout.endpoint": "${api.url}/api/auth/logout",
"login.turnstile.enabled": false,
"login.turnstile.sitekey": "",
"avatar.mandatory.libraries": [ "avatar.mandatory.libraries": [
"bd:1", "bd:1",
"li:0" "li:0"
@@ -27,6 +27,18 @@
"guides.enabled": true, "guides.enabled": true,
"toolbar.hide.quests": true, "toolbar.hide.quests": true,
"catalog.style.new": true, "catalog.style.new": true,
"show.google.ads": false,
"loginview": {
"images": {
"background": "${asset.url}/c_images/reception/stretch_blue.png",
"background.colour": "#6eadc8",
"sun": "${asset.url}/c_images/reception/sun.png",
"drape": "${asset.url}/c_images/reception/drape.png",
"left": "${asset.url}/c_images/reception/ts.png",
"right": "${asset.url}/c_images/reception/US_right.png",
"right.repeat": "${asset.url}/c_images/reception/US_top_right.png"
}
},
"navigator.room.models": [ "navigator.room.models": [
{ {
"clubLevel": 0, "clubLevel": 0,
+46 -2
View File
@@ -3,6 +3,7 @@ import { FC, useCallback, useEffect, useState } from 'react';
import { GetUIVersion } from './api'; import { GetUIVersion } from './api';
import { Base } from './common'; import { Base } from './common';
import { LoadingView } from './components/loading/LoadingView'; import { LoadingView } from './components/loading/LoadingView';
import { LoginView } from './components/login/LoginView';
import { MainView } from './components/MainView'; import { MainView } from './components/MainView';
import { ReconnectView } from './components/reconnect/ReconnectView'; import { ReconnectView } from './components/reconnect/ReconnectView';
import { useMessageEvent, useNitroEvent } from './hooks'; import { useMessageEvent, useNitroEvent } from './hooks';
@@ -14,12 +15,24 @@ export const App: FC<{}> = props =>
const [ isReady, setIsReady ] = useState(false); const [ isReady, setIsReady ] = useState(false);
const [ errorMessage, setErrorMessage ] = useState(''); const [ errorMessage, setErrorMessage ] = useState('');
const [ homeUrl, setHomeUrl ] = useState(''); const [ homeUrl, setHomeUrl ] = useState('');
const [ showLogin, setShowLogin ] = useState(false);
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
const showSessionExpired = useCallback(() => const showSessionExpired = useCallback(() =>
{ {
const baseUrl = window.location.origin + '/'; const baseUrl = window.location.origin + '/';
setHomeUrl(baseUrl); setHomeUrl(baseUrl);
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.'); setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
setIsReady(false); setIsReady(false);
setShowLogin(false);
}, []);
const handleAuthenticated = useCallback((ssoTicket: string) =>
{
if(!ssoTicket) return;
window.NitroConfig['sso.ticket'] = ssoTicket;
setShowLogin(false);
setErrorMessage('');
setPrepareTrigger(prev => prev + 1);
}, []); }, []);
// Listen for socket closed events (code 1000 "Bye" - server rejected SSO) // Listen for socket closed events (code 1000 "Bye" - server rejected SSO)
@@ -48,6 +61,36 @@ export const App: FC<{}> = props =>
if(!ssoTicket || ssoTicket === '') if(!ssoTicket || ssoTicket === '')
{ {
// Configuration is loaded lazily — fetch it up-front so the login
// screen toggle and Turnstile keys are available before we decide.
let configInitError: unknown = null;
try { await GetConfiguration().init(); }
catch(e) { configInitError = e; }
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
if(configInitError)
{
NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError);
}
if(loginScreenEnabled)
{
setIsReady(false);
setShowLogin(true);
return;
}
if(configInitError)
{
setHomeUrl(window.location.origin + '/');
setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`);
setIsReady(false);
setShowLogin(false);
return;
}
showSessionExpired(); showSessionExpired();
return; return;
} }
@@ -120,12 +163,13 @@ export const App: FC<{}> = props =>
{ {
if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval); if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval);
}; };
}, []); }, [ prepareTrigger ]);
return ( return (
<Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }> <Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }>
{ !isReady && { !isReady && !showLogin &&
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> } <LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } /> }
{ isReady && <MainView /> } { isReady && <MainView /> }
<ReconnectView /> <ReconnectView />
<Base id="draggable-windows-container" /> <Base id="draggable-windows-container" />
@@ -87,6 +87,9 @@ export class AvatarEditorThumbnailsHelper
AvatarFigurePartType.PET, AvatarFigurePartType.PET,
'ptl', 'ptl',
'ptr', 'ptr',
AvatarFigurePartType.MISC,
'mcl',
'mcr',
]; ];
private static getThumbnailKey(setType: string, part: IAvatarEditorCategoryPartItem, partColors?: IPartColor[], isDisabled?: boolean): string private static getThumbnailKey(setType: string, part: IAvatarEditorCategoryPartItem, partColors?: IPartColor[], isDisabled?: boolean): string
@@ -0,0 +1,64 @@
import { AvatarFigureContainer, GetAvatarRenderManager, IFigurePartSet } from '@nitrots/nitro-renderer';
const getFirstSelectableColorForSetType = (setType: string): number =>
{
const structure = GetAvatarRenderManager()?.structureData;
if(!structure) return -1;
const set = structure.getSetType(setType);
if(!set) return -1;
const palette = structure.getPalette(set.paletteID);
if(!palette) return -1;
for(const color of palette.colors.getValues())
{
if(!color || !color.isSelectable) continue;
return color.id;
}
return -1;
};
/**
* Builds a new figure string starting from the base figure and applying the
* provided figure part set IDs (e.g. a purchasable clothing set or pet set).
*
* When the base figure does not already define colours for the set type being
* applied (common for pet "pt" sets on an avatar that has never worn one), the
* first selectable palette colour is used so the part still renders instead of
* being dropped.
*/
export const BuildPurchasableClothingFigure = (baseFigure: string, setIds: number[]): string =>
{
const manager = GetAvatarRenderManager();
if(!manager) return baseFigure;
const container = new AvatarFigureContainer(baseFigure ?? '');
const structure = manager.structureData;
for(const setId of setIds)
{
const partSet: IFigurePartSet = structure?.getFigurePartSet(setId);
if(!partSet) continue;
let colorIds = container.getPartColorIds(partSet.type) ?? [];
if(!colorIds.length)
{
const defaultColor = getFirstSelectableColorForSetType(partSet.type);
if(defaultColor >= 0) colorIds = [ defaultColor ];
}
container.updatePart(partSet.type, partSet.id, colorIds);
}
return container.getFigureString();
};
+1
View File
@@ -2,5 +2,6 @@ export * from './AvatarEditorAction';
export * from './AvatarEditorColorSorter'; export * from './AvatarEditorColorSorter';
export * from './AvatarEditorPartSorter'; export * from './AvatarEditorPartSorter';
export * from './AvatarEditorThumbnailsHelper'; export * from './AvatarEditorThumbnailsHelper';
export * from './BuildPurchasableClothingFigure';
export * from './IAvatarEditorCategory'; export * from './IAvatarEditorCategory';
export * from './IAvatarEditorCategoryPartItem'; export * from './IAvatarEditorCategoryPartItem';
+10
View File
@@ -117,4 +117,14 @@ export class FurnitureOffer implements IPurchasableOffer
{ {
return true; return true;
} }
public get itemIds(): string
{
return String(this._furniData?.id ?? '');
}
public get haveOffer(): boolean
{
return false;
}
} }
+2
View File
@@ -22,4 +22,6 @@ export interface IPurchasableOffer
localizationDescription: string; localizationDescription: string;
isLazy: boolean; isLazy: boolean;
products: IProduct[]; products: IProduct[];
itemIds: string;
haveOffer: boolean;
} }
+16 -2
View File
@@ -30,8 +30,10 @@ export class Offer implements IPurchasableOffer
private _products: IProduct[]; private _products: IProduct[];
private _badgeCode: string; private _badgeCode: string;
private _bundlePurchaseAllowed: boolean = false; private _bundlePurchaseAllowed: boolean = false;
private _itemIds: string = '';
private _haveOffer: boolean = false;
constructor(offerId: number, localizationId: string, isRentOffer: boolean, priceInCredits: number, priceInActivityPoints: number, activityPointType: number, giftable: boolean, clubLevel: number, products: IProduct[], bundlePurchaseAllowed: boolean) constructor(offerId: number, localizationId: string, isRentOffer: boolean, priceInCredits: number, priceInActivityPoints: number, activityPointType: number, giftable: boolean, clubLevel: number, products: IProduct[], bundlePurchaseAllowed: boolean, itemIds: string = '', haveOffer: boolean = false)
{ {
this._offerId = offerId; this._offerId = offerId;
this._localizationId = localizationId; this._localizationId = localizationId;
@@ -43,6 +45,8 @@ export class Offer implements IPurchasableOffer
this._clubLevel = clubLevel; this._clubLevel = clubLevel;
this._products = products; this._products = products;
this._bundlePurchaseAllowed = bundlePurchaseAllowed; this._bundlePurchaseAllowed = bundlePurchaseAllowed;
this._itemIds = itemIds || '';
this._haveOffer = haveOffer;
this.setPricingModelForProducts(); this.setPricingModelForProducts();
this.setPricingType(); this.setPricingType();
@@ -182,6 +186,16 @@ export class Offer implements IPurchasableOffer
return this._products; return this._products;
} }
public get itemIds(): string
{
return this._itemIds;
}
public get haveOffer(): boolean
{
return this._haveOffer;
}
private setPricingModelForProducts(): void private setPricingModelForProducts(): void
{ {
const products = Product.stripAddonProducts(this._products); const products = Product.stripAddonProducts(this._products);
@@ -244,7 +258,7 @@ export class Offer implements IPurchasableOffer
products.push(new Product(product.productType, product.productClassId, product.extraParam, product.productCount, productData, furnitureData)); products.push(new Product(product.productType, product.productClassId, product.extraParam, product.productCount, productData, furnitureData));
} }
const offer = new Offer(this.offerId, this.localizationId, this.isRentOffer, this.priceInCredits, this.priceInActivityPoints, this.activityPointType, this.giftable, this.clubLevel, products, this.bundlePurchaseAllowed); const offer = new Offer(this.offerId, this.localizationId, this.isRentOffer, this.priceInCredits, this.priceInActivityPoints, this.activityPointType, this.giftable, this.clubLevel, products, this.bundlePurchaseAllowed, this.itemIds, this.haveOffer);
offer.page = this.page; offer.page = this.page;
Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

+2
View File
@@ -25,6 +25,7 @@ import { NavigatorView } from './navigator/NavigatorView';
import { NitrobubbleHiddenView } from './nitrobubblehidden/NitrobubbleHiddenView'; import { NitrobubbleHiddenView } from './nitrobubblehidden/NitrobubbleHiddenView';
import { NitropediaView } from './nitropedia/NitropediaView'; import { NitropediaView } from './nitropedia/NitropediaView';
import { ExternalPluginLoader } from './plugins/ExternalPluginLoader'; import { ExternalPluginLoader } from './plugins/ExternalPluginLoader';
import { GoogleAdsView } from './ads/GoogleAdsView';
import { RightSideView } from './right-side/RightSideView'; import { RightSideView } from './right-side/RightSideView';
import { RoomView } from './room/RoomView'; import { RoomView } from './room/RoomView';
import { ToolbarView } from './toolbar/ToolbarView'; import { ToolbarView } from './toolbar/ToolbarView';
@@ -112,6 +113,7 @@ export const MainView: FC<{}> = props =>
</AnimatePresence> </AnimatePresence>
<ToolbarView isInRoom={ !landingViewVisible } /> <ToolbarView isInRoom={ !landingViewVisible } />
<TranslationBootstrap /> <TranslationBootstrap />
<GoogleAdsView />
<ModToolsView /> <ModToolsView />
<WiredCreatorToolsView /> <WiredCreatorToolsView />
<RoomView /> <RoomView />
+164
View File
@@ -0,0 +1,164 @@
import { FC, useEffect, useRef, useState } from 'react';
import { GetConfigurationValue } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
interface AdsenseConfig {
slot: string;
format?: string;
fullWidthResponsive?: boolean;
}
const ADSENSE_SCRIPT_ID = 'google-adsense-script';
const parsePublisherIdFromAdsTxt = (text: string): string | null => {
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.split('#')[0].trim();
if (!line) continue;
const parts = line.split(',').map(part => part.trim());
if (parts.length < 2) continue;
if (parts[0].toLowerCase() !== 'google.com') continue;
const pub = parts[1];
if (/^pub-\d+$/.test(pub)) return pub;
}
return null;
};
const ensureAdsenseScript = (publisherId: string): void => {
if (typeof document === 'undefined') return;
if (document.getElementById(ADSENSE_SCRIPT_ID)) return;
const script = document.createElement('script');
script.id = ADSENSE_SCRIPT_ID;
script.async = true;
script.crossOrigin = 'anonymous';
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ publisherId }`;
document.head.appendChild(script);
};
export const GoogleAdsView: FC<{}> = () => {
const adsEnabled = GetConfigurationValue<boolean>('show.google.ads', false);
const [ isOpen, setIsOpen ] = useState(false);
const [ publisherId, setPublisherId ] = useState<string | null>(null);
const [ config, setConfig ] = useState<AdsenseConfig | null>(null);
const [ loadError, setLoadError ] = useState<string | null>(null);
const insRef = useRef<HTMLModElement>(null);
const pushedRef = useRef(false);
const autoOpenedRef = useRef(false);
useEffect(() => {
if (!adsEnabled) return;
const handler = () => setIsOpen(prev => !prev);
window.addEventListener('ads:toggle', handler);
return () => window.removeEventListener('ads:toggle', handler);
}, [ adsEnabled ]);
// Auto-open once on initial mount (the login / landing stage).
// Subsequent toggles are driven by the "ads:toggle" window event
// (e.g. the Show Ad button in NitroSystemAlertView).
useEffect(() => {
if (!adsEnabled) return;
if (autoOpenedRef.current) return;
autoOpenedRef.current = true;
const t = setTimeout(() => setIsOpen(true), 500);
return () => clearTimeout(t);
}, [ adsEnabled ]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [ adsTxtRes, configRes ] = await Promise.all([
fetch('/ads.txt', { cache: 'no-cache' }),
fetch('/adsense.json', { cache: 'no-cache' })
]);
if (!adsTxtRes.ok) throw new Error(`ads.txt ${ adsTxtRes.status }`);
const adsTxt = await adsTxtRes.text();
const pubId = parsePublisherIdFromAdsTxt(adsTxt);
if (!pubId) throw new Error('No google.com publisher id in ads.txt');
let cfg: AdsenseConfig = { slot: '', format: 'auto', fullWidthResponsive: true };
if (configRes.ok) cfg = { ...cfg, ...(await configRes.json()) };
if (cancelled) return;
setPublisherId(pubId);
setConfig(cfg);
} catch (err) {
if (!cancelled) setLoadError((err as Error).message);
}
})();
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!isOpen || !publisherId || !config) return;
ensureAdsenseScript(publisherId);
}, [ isOpen, publisherId, config ]);
useEffect(() => {
if (!isOpen) {
pushedRef.current = false;
return;
}
if (!insRef.current || pushedRef.current) return;
if (!publisherId || !config?.slot) return;
const tryPush = () => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = window as any;
w.adsbygoogle = w.adsbygoogle || [];
w.adsbygoogle.push({});
pushedRef.current = true;
} catch {
// AdSense script may not be ready yet; retry once
setTimeout(() => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const w = window as any;
w.adsbygoogle = w.adsbygoogle || [];
w.adsbygoogle.push({});
pushedRef.current = true;
} catch { /* give up */ }
}, 500);
}
};
const t = setTimeout(tryPush, 50);
return () => clearTimeout(t);
}, [ isOpen, publisherId, config ]);
if (!adsEnabled) return null;
if (!isOpen) return null;
return (
<NitroCardView className="nitro-google-ads" uniqueKey="google-ads" theme="primary">
<NitroCardHeaderView headerText="Sponsored" onCloseClick={ () => setIsOpen(false) } />
<NitroCardContentView>
<div className="flex items-center justify-center w-[300px] h-[250px] bg-white">
{ loadError &&
<div className="text-xs text-red-600 text-center px-2">Ads unavailable: { loadError }</div> }
{ !loadError && (!publisherId || !config) &&
<div className="text-xs text-gray-500">Loading</div> }
{ !loadError && publisherId && config?.slot &&
<ins
ref={ insRef }
key={ `${ publisherId }-${ config.slot }` }
className="adsbygoogle"
style={ { display: 'block', width: '100%', height: '100%' } }
data-ad-client={ `ca-${ publisherId }` }
data-ad-slot={ config.slot }
data-ad-format={ config.format ?? 'auto' }
data-full-width-responsive={ (config.fullWidthResponsive ?? true) ? 'true' : 'false' }
/> }
{ !loadError && publisherId && config && !config.slot &&
<div className="text-xs text-gray-500 text-center px-2">Ad slot not configured in adsense.json</div> }
</div>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -88,11 +88,13 @@ export const AvatarEditorView: FC<{}> = props =>
const isWardrobe = (modelKey === AvatarEditorFigureCategory.WARDROBE); const isWardrobe = (modelKey === AvatarEditorFigureCategory.WARDROBE);
const isPets = (modelKey === AvatarEditorFigureCategory.PETS); const isPets = (modelKey === AvatarEditorFigureCategory.PETS);
const isNft = (modelKey === AvatarEditorFigureCategory.NFT); const isNft = (modelKey === AvatarEditorFigureCategory.NFT);
const isMisc = (modelKey === AvatarEditorFigureCategory.MISC);
let tabClass = `tab ${ modelKey }`; let tabClass = `tab ${ modelKey }`;
if(isWardrobe) tabClass = 'tab-wardrobe'; if(isWardrobe) tabClass = 'tab-wardrobe';
else if(isPets) tabClass = 'tab-pets'; else if(isPets) tabClass = 'tab-pets';
else if(isNft) tabClass = 'tab-nft'; else if(isNft) tabClass = 'tab-nft';
else if(isMisc) tabClass = 'tab-misc';
return ( return (
<NitroCardTabsItemView key={ modelKey } isActive={ isActive } onClick={ event => setActiveModelKey(modelKey) }> <NitroCardTabsItemView key={ modelKey } isActive={ isActive } onClick={ event => setActiveModelKey(modelKey) }>
@@ -84,7 +84,7 @@ export const AvatarEditorFigureSetItemView: FC<{
<img <img
src={ assetUrl } src={ assetUrl }
alt="" alt=""
className="absolute inset-0 w-full h-full object-contain pointer-events-none image-rendering-pixelated" className="max-w-full max-h-full pointer-events-none image-rendering-pixelated"
draggable={ false } draggable={ false }
/> } /> }
{ !partItem.isClear && isHC && <LayoutCurrencyIcon className="absolute inset-e-1 bottom-1" type="hc" /> } { !partItem.isClear && isHC && <LayoutCurrencyIcon className="absolute inset-e-1 bottom-1" type="hc" /> }
@@ -207,7 +207,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
setLastError(null); setLastError(null);
pendingActionRef.current = 'saveOffer'; pendingActionRef.current = 'saveOffer';
SendMessageComposer(new CatalogAdminSaveOfferComposer( SendMessageComposer(new CatalogAdminSaveOfferComposer(
data.offerId || 0, data.pageId, parseInt(data.itemIds) || 0, data.offerId || 0, data.pageId, data.itemIds || '',
data.catalogName, data.costCredits, data.costPoints, data.pointsType, data.catalogName, data.costCredits, data.costPoints, data.pointsType,
data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata, data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata,
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType
@@ -220,7 +220,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
setLastError(null); setLastError(null);
pendingActionRef.current = 'createOffer'; pendingActionRef.current = 'createOffer';
SendMessageComposer(new CatalogAdminCreateOfferComposer( SendMessageComposer(new CatalogAdminCreateOfferComposer(
data.pageId, parseInt(data.itemIds) || 0, data.pageId, data.itemIds || '',
data.catalogName, data.costCredits, data.costPoints, data.pointsType, data.catalogName, data.costCredits, data.costPoints, data.pointsType,
data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata, data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata,
data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType
@@ -16,7 +16,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
const createOffer = catalogAdmin?.createOffer; const createOffer = catalogAdmin?.createOffer;
const loading = catalogAdmin?.loading ?? false; const loading = catalogAdmin?.loading ?? false;
const [ itemIds, setItemIds ] = useState('0'); const [ itemIds, setItemIds ] = useState('');
const [ catalogName, setCatalogName ] = useState(''); const [ catalogName, setCatalogName ] = useState('');
const [ costCredits, setCostCredits ] = useState(0); const [ costCredits, setCostCredits ] = useState(0);
const [ costPoints, setCostPoints ] = useState(0); const [ costPoints, setCostPoints ] = useState(0);
@@ -37,7 +37,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
if(editingOffer.offerId === -1) if(editingOffer.offerId === -1)
{ {
setIsNew(true); setIsNew(true);
setItemIds('0'); setItemIds('');
setCatalogName(''); setCatalogName('');
setCostCredits(0); setCostCredits(0);
setCostPoints(0); setCostPoints(0);
@@ -53,7 +53,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
else else
{ {
setIsNew(false); setIsNew(false);
setItemIds(String(editingOffer.product?.productClassId || 0)); setItemIds(editingOffer.itemIds || '');
setCatalogName(editingOffer.localizationName || ''); setCatalogName(editingOffer.localizationName || '');
setCostCredits(editingOffer.priceInCredits); setCostCredits(editingOffer.priceInCredits);
setCostPoints(editingOffer.priceInActivityPoints); setCostPoints(editingOffer.priceInActivityPoints);
@@ -61,7 +61,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
setAmount(editingOffer.product?.productCount || 1); setAmount(editingOffer.product?.productCount || 1);
setClubOnly(editingOffer.clubLevel > 0 ? '1' : '0'); setClubOnly(editingOffer.clubLevel > 0 ? '1' : '0');
setExtradata(editingOffer.product?.extraParam || ''); setExtradata(editingOffer.product?.extraParam || '');
setHaveOffer('1'); setHaveOffer(editingOffer.haveOffer ? '1' : '0');
setOfferIdGroup(editingOffer.offerId || -1); setOfferIdGroup(editingOffer.offerId || -1);
setLimitedStack(0); setLimitedStack(0);
setOrderNumber(0); setOrderNumber(0);
@@ -104,7 +104,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
if(setEditingOffer) setEditingOffer(null); if(setEditingOffer) setEditingOffer(null);
}; };
const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white focus:outline-none focus:border-primary transition-colors'; const inputClass = 'text-[11px] border-2 border-card-grid-item-border rounded px-2 py-1 bg-white placeholder:text-[#4b5563] focus:outline-none focus:border-primary transition-colors';
return createPortal( return createPortal(
<div className="fixed inset-0 flex items-center justify-center" style={ { zIndex: 1000 } } onClick={ () => setEditingOffer(null) }> <div className="fixed inset-0 flex items-center justify-center" style={ { zIndex: 1000 } } onClick={ () => setEditingOffer(null) }>
@@ -140,7 +140,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">Item IDs</label> <label className="text-[9px] text-muted">Item IDs</label>
<input className={ inputClass } placeholder="1234" type="text" value={ itemIds } onChange={ e => setItemIds(e.target.value) } /> <input className={ inputClass } placeholder="1234 or 100;200" type="text" value={ itemIds } onChange={ e => setItemIds(e.target.value) } />
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.quantity') }</label> <label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.quantity') }</label>
@@ -198,7 +198,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
</div> </div>
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.extradata') }</label> <label className="text-[9px] text-muted">{ LocalizeText('catalog.admin.offer.extradata') }</label>
<input className={ inputClass } placeholder="dati extra (opzionale)" type="text" value={ extradata } onChange={ e => setExtradata(e.target.value) } /> <input className={ inputClass } placeholder={ LocalizeText('catalog.admin.offer.extradata') } type="text" value={ extradata } onChange={ e => setExtradata(e.target.value) } />
</div> </div>
<div className="flex items-center gap-1.5 mt-1.5"> <div className="flex items-center gap-1.5 mt-1.5">
<input className="accent-primary" checked={ haveOffer === '1' } id="haveOffer" type="checkbox" onChange={ e => setHaveOffer(e.target.checked ? '1' : '0') } /> <input className="accent-primary" checked={ haveOffer === '1' } id="haveOffer" type="checkbox" onChange={ e => setHaveOffer(e.target.checked ? '1' : '0') } />
@@ -1,6 +1,6 @@
import { GetAvatarRenderManager, GetSessionDataManager, Vector3d } from '@nitrots/nitro-renderer'; import { GetAvatarRenderManager, GetSessionDataManager, Vector3d } from '@nitrots/nitro-renderer';
import { FC, useEffect } from 'react'; import { FC, useEffect } from 'react';
import { FurniCategory, Offer, ProductTypeEnum } from '../../../../../api'; import { BuildPurchasableClothingFigure, FurniCategory, Offer, ProductTypeEnum } from '../../../../../api';
import { AutoGrid, Column, LayoutGridItem, LayoutRoomPreviewerView } from '../../../../../common'; import { AutoGrid, Column, LayoutGridItem, LayoutRoomPreviewerView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks'; import { useCatalog } from '../../../../../hooks';
@@ -24,18 +24,37 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
case ProductTypeEnum.FLOOR: { case ProductTypeEnum.FLOOR: {
if(!product.furnitureData) return; if(!product.furnitureData) return;
if(product.furnitureData.specialType === FurniCategory.FIGURE_PURCHASABLE_SET) const furniData = GetSessionDataManager().getFloorItemData(product.furnitureData.id);
const isPurchasableClothing = (product.furnitureData.specialType === FurniCategory.FIGURE_PURCHASABLE_SET);
const hasResolvableFigureSets = (() =>
{
if(!furniData || !furniData.customParams || !furniData.customParams.length) return false;
const parts = furniData.customParams.split(',').map(value => parseInt(value));
for(const part of parts)
{
if(isNaN(part)) continue;
if(GetAvatarRenderManager().structureData?.getFigurePartSet(part)) return true;
}
return false;
})();
if(isPurchasableClothing || hasResolvableFigureSets)
{ {
const furniData = GetSessionDataManager().getFloorItemData(product.furnitureData.id);
const customParts = furniData.customParams.split(',').map(value => parseInt(value)); const customParts = furniData.customParams.split(',').map(value => parseInt(value));
const figureSets: number[] = []; const figureSets: number[] = [];
for(const part of customParts) for(const part of customParts)
{ {
if(isNaN(part)) continue;
if(GetAvatarRenderManager().isValidFigureSetForGender(part, GetSessionDataManager().gender)) figureSets.push(part); if(GetAvatarRenderManager().isValidFigureSetForGender(part, GetSessionDataManager().gender)) figureSets.push(part);
} }
const figureString = GetAvatarRenderManager().getFigureStringWithFigureIds(GetSessionDataManager().figure, GetSessionDataManager().gender, figureSets); const figureString = BuildPurchasableClothingFigure(GetSessionDataManager().figure, figureSets);
roomPreviewer.addAvatarIntoRoom(figureString, product.productClassId); roomPreviewer.addAvatarIntoRoom(figureString, product.productClassId);
} }
@@ -38,7 +38,7 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount
{ (requestsCount > 0) && { (requestsCount > 0) &&
<motion.div variants={ itemVariants }> <motion.div variants={ itemVariants }>
<div className="flex h-[34px] items-center rounded-[7px] border border-[#9fc56f] bg-[#5f7d2f] px-[10px] text-[0.74rem] font-bold whitespace-nowrap text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_2px_0_rgba(0,0,0,0.25)]"> <div className="flex h-[34px] items-center rounded-[7px] border border-[#9fc56f] bg-[#5f7d2f] px-[10px] text-[0.74rem] font-bold whitespace-nowrap text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.18),0_2px_0_rgba(0,0,0,0.25)]">
{ requestsCount } richieste { requestsCount } { LocalizeText('friendbar.requests.title') }
</div> </div>
</motion.div> } </motion.div> }
<motion.div variants={itemVariants}> <motion.div variants={itemVariants}>
+56 -53
View File
@@ -1,6 +1,10 @@
import { AddLinkEventTracker, ClubGiftInfoEvent, CreateLinkEvent, GetClubGiftInfo, ILinkEventTracker, RemoveLinkEventTracker, ScrGetKickbackInfoMessageComposer, ScrKickbackData, ScrSendKickbackInfoMessageEvent } from '@nitrots/nitro-renderer'; import { AddLinkEventTracker, ClubGiftInfoEvent, CreateLinkEvent, GetClubGiftInfo, ILinkEventTracker, RemoveLinkEventTracker, ScrGetKickbackInfoMessageComposer, ScrKickbackData, ScrSendKickbackInfoMessageEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { ClubStatus, FriendlyTime, GetClubBadge, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../api'; import { ClubStatus, FriendlyTime, GetClubBadge, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../api';
import hcLogo from '../../assets/images/hc-center/hc_logo.gif';
import paydayBg from '../../assets/images/hc-center/payday.png';
import clockIcon from '../../assets/images/hc-center/clock.png';
import benefitsBg from '../../assets/images/hc-center/benefits.png';
import { Button, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; import { Button, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useInventoryBadges, useMessageEvent, usePurse, useSessionInfo } from '../../hooks'; import { useInventoryBadges, useMessageEvent, usePurse, useSessionInfo } from '../../hooks';
@@ -126,73 +130,72 @@ export const HcCenterView: FC<{}> = props =>
); );
return ( return (
<NitroCardView className="nitro-hc-center" theme="primary-slim"> <NitroCardView className="w-[430px] resize-none" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('generic.hccenter') } onCloseClick={ () => setIsVisible(false) } /> <NitroCardHeaderView headerText={ LocalizeText('generic.hccenter') } onCloseClick={ () => setIsVisible(false) } />
<Flex className="bg-muted p-2" position="relative"> <Flex className="bg-muted/50 p-3" position="relative">
<div className="flex flex-col gap-1"> <Column gap={ 2 }>
<div className="hc-logo" /> <div className="w-[213px] h-[37px] bg-contain bg-no-repeat" style={ { backgroundImage: `url(${ hcLogo })` } } />
<Flex> <Button variant="success" onClick={ event => CreateLinkEvent('catalog/open/' + GetConfigurationValue('catalog.links')['hc.buy_hc']) }>
<Button variant="success" onClick={ event => CreateLinkEvent('catalog/open/' + GetConfigurationValue('catalog.links')['hc.buy_hc']) }> { LocalizeText((clubStatus === ClubStatus.ACTIVE) ? 'hccenter.btn.extend' : 'hccenter.btn.buy') }
{ LocalizeText((clubStatus === ClubStatus.ACTIVE) ? 'hccenter.btn.extend' : 'hccenter.btn.buy') } </Button>
</Button> </Column>
</Flex> <div className="absolute right-0 top-0 p-2 z-[4]">
</div>
<div className="inset-e-0 p-4 top-0 habbo-avatar absolute">
<LayoutAvatarImageView direction={ 4 } figure={ userFigure } scale={ 2 } /> <LayoutAvatarImageView direction={ 4 } figure={ userFigure } scale={ 2 } />
</div> </div>
</Flex> </Flex>
<NitroCardContentView> <NitroCardContentView>
<div className="flex gap-2"> <Flex gap={ 2 } alignItems="center" className="p-2 rounded bg-card-grid-item/30">
<LayoutBadgeImageView badgeCode={ badgeCode } className="align-self-center shrink-0 me-1" /> <LayoutBadgeImageView badgeCode={ badgeCode } className="shrink-0" />
<Column className="streak-info" gap={ 0 } size={ 5 }> <Column gap={ 0 } className="min-h-[48px] leading-4">
<Text>{ LocalizeText('hccenter.status.' + clubStatus) }</Text> <Text bold>{ LocalizeText('hccenter.status.' + clubStatus) }</Text>
<Text dangerouslySetInnerHTML={ { __html: getInfoText() } } /> <Text small className="text-gray-700" dangerouslySetInnerHTML={ { __html: getInfoText() } } />
</Column> </Column>
</div> </Flex>
{ GetConfigurationValue('hc.center')['payday.info'] && { GetConfigurationValue('hc.center')['payday.info'] &&
<Flex alignItems="center"> <Flex className="rounded overflow-hidden border border-card-grid-item-border">
<Column className="bg-primary p-3 flex-1 text-white" gap={ 1 }>
<Column className="rounded-start bg-primary p-2 payday-special mb-1"> <Text bold className="text-white">{ LocalizeText('hccenter.special.title') }</Text>
<h4 className="mb-1">{ LocalizeText('hccenter.special.title') }</h4> <Text small className="text-white/80">{ LocalizeText('hccenter.special.info') }</Text>
<div>{ LocalizeText('hccenter.special.info') }</div> <div className="mt-auto">
<div className="btn btn-link text-white p-0 mt-auto align-self-baseline" onClick={ () => CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['payday.habbopage']) }>{ LocalizeText('hccenter.special.infolink') }</div> <span className="text-white/90 text-sm cursor-pointer hover:underline" onClick={ () => CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['payday.habbopage']) }>
</Column> { LocalizeText('hccenter.special.infolink') }
<div className="payday shrink-0 p-2"> </span>
<h5 className="mb-2 ms-2">{ LocalizeText('hccenter.special.time.title') }</h5>
<div className="flex flex-row mb-2">
<div className="clock me-2" />
<h6 className="mb-0 align-self-center">{ getHcPaydayTime() }</h6>
</div> </div>
</Column>
<Column className="w-[200px] shrink-0 p-3 bg-contain bg-no-repeat bg-center text-[#6b3502]" gap={ 1 } style={ { backgroundImage: `url(${ paydayBg })` } }>
<Text bold small>{ LocalizeText('hccenter.special.time.title') }</Text>
<Flex gap={ 1 } alignItems="center">
<div className="w-5 h-5 shrink-0 bg-contain bg-no-repeat bg-center" style={ { backgroundImage: `url(${ clockIcon })` } } />
<Text bold>{ getHcPaydayTime() }</Text>
</Flex>
{ clubStatus === ClubStatus.ACTIVE && { clubStatus === ClubStatus.ACTIVE &&
<div className="pe-3"> <Column gap={ 0 } className="mt-1">
<h5 className="ms-2 mb-1 bolder">{ LocalizeText('hccenter.special.amount.title') }</h5> <Text bold small>{ LocalizeText('hccenter.special.amount.title') }</Text>
<div className="flex flex-col"> <Text bold className="text-center">{ getHcPaydayAmount() }</Text>
<div className="w-full text-center ms-4n">{ getHcPaydayAmount() }</div> <span className="text-primary text-sm cursor-pointer hover:underline self-end" onClick={ () => CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['payday.habbopage']) }>
<div className="btn btn-link align-self-end text-primary"> { LocalizeText('hccenter.breakdown.infolink') }
{ LocalizeText('hccenter.breakdown.infolink') } </span>
</div> </Column> }
</div> </Column>
</div> }
</div>
</Flex> } </Flex> }
{ GetConfigurationValue('hc.center')['gift.info'] && { GetConfigurationValue('hc.center')['gift.info'] &&
<div className="rounded bg-success p-2 flex flex-row mb-0"> <Flex className="rounded bg-success/90 p-3" alignItems="center" gap={ 2 }>
<div> <Column gap={ 0 } className="flex-1">
<h4 className="mb-1">{ LocalizeText('hccenter.gift.title') }</h4> <Text bold className="text-white">{ LocalizeText('hccenter.gift.title') }</Text>
<div dangerouslySetInnerHTML={ { __html: unclaimedGifts > 0 ? LocalizeText('hccenter.unclaimedgifts', [ 'unclaimedgifts' ], [ unclaimedGifts.toString() ]) : LocalizeText('hccenter.gift.info') } }></div> <Text small className="text-white/80" dangerouslySetInnerHTML={ { __html: unclaimedGifts > 0 ? LocalizeText('hccenter.unclaimedgifts', [ 'unclaimedgifts' ], [ unclaimedGifts.toString() ]) : LocalizeText('hccenter.gift.info') } } />
</div> </Column>
<button className="btn btn-primary btn-lg align-self-center ms-auto" onClick={ () => CreateLinkEvent('catalog/open/' + GetConfigurationValue('catalog.links')['hc.hc_gifts']) }> <Button variant="primary" className="shrink-0" onClick={ () => CreateLinkEvent('catalog/open/' + GetConfigurationValue('catalog.links')['hc.hc_gifts']) }>
{ LocalizeText(clubStatus === ClubStatus.ACTIVE ? 'hccenter.btn.gifts.redeem' : 'hccenter.btn.gifts.view') } { LocalizeText(clubStatus === ClubStatus.ACTIVE ? 'hccenter.btn.gifts.redeem' : 'hccenter.btn.gifts.view') }
</button> </Button>
</div> } </Flex> }
{ GetConfigurationValue('hc.center')['benefits.info'] && { GetConfigurationValue('hc.center')['benefits.info'] &&
<div className="benefits text-black py-2"> <Column className="rounded p-3 bg-no-repeat bg-right-top border border-card-grid-item-border" gap={ 1 } style={ { backgroundImage: `url(${ benefitsBg })` } }>
<h5 className="mb-1 text-primary">{ LocalizeText('hccenter.general.title') }</h5> <Text bold variant="primary">{ LocalizeText('hccenter.general.title') }</Text>
<div className="mb-2" dangerouslySetInnerHTML={ { __html: LocalizeText('hccenter.general.info') } } /> <Text small className="text-gray-700" dangerouslySetInnerHTML={ { __html: LocalizeText('hccenter.general.info') } } />
<button className="btn btn-link p-0 text-primary" onClick={ () => CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['benefits.habbopage']) }> <span className="text-primary text-sm cursor-pointer hover:underline mt-1" onClick={ () => CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['benefits.habbopage']) }>
{ LocalizeText('hccenter.general.infolink') } { LocalizeText('hccenter.general.infolink') }
</button> </span>
</div> } </Column> }
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
-1
View File
@@ -9,7 +9,6 @@
@import './friends/FriendsView'; @import './friends/FriendsView';
@import './groups/GroupView'; @import './groups/GroupView';
@import './guide-tool/GuideToolView'; @import './guide-tool/GuideToolView';
@import './hc-center/HcCenterView';
@import './help/HelpView'; @import './help/HelpView';
@import './hotel-view/HotelView'; @import './hotel-view/HotelView';
@import './loading/LoadingView'; @import './loading/LoadingView';
+598
View File
@@ -0,0 +1,598 @@
import { GetConfiguration } from '@nitrots/nitro-renderer';
import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { GetConfigurationValue } from '../../api';
import { TurnstileWidget } from './TurnstileWidget';
type DialogMode = 'login' | 'register' | 'forgot';
const interpolate = (value: string | null | undefined): string =>
{
if(!value) return '';
try { return GetConfiguration().interpolate(value); }
catch { return value; }
};
const LOCK_KEY = 'nitro.login.lock';
const MAX_ATTEMPTS = 5;
const LOCK_WINDOW_MS = 60_000; // rolling 60s window
const LOCK_DURATION_MS = 2 * 60_000; // 2 minute lockout
type AttemptState = { attempts: number; firstAt: number; lockedUntil: number };
const readLock = (): AttemptState =>
{
try
{
const raw = sessionStorage.getItem(LOCK_KEY);
if(!raw) return { attempts: 0, firstAt: 0, lockedUntil: 0 };
return JSON.parse(raw);
}
catch { return { attempts: 0, firstAt: 0, lockedUntil: 0 }; }
};
const writeLock = (state: AttemptState) =>
{
try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); }
catch { /* ignore */ }
};
export interface LoginViewProps
{
onAuthenticated: (ssoTicket: string) => void;
}
export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
{
const [ mode, setMode ] = useState<DialogMode>('login');
const [ username, setUsername ] = useState('');
const [ password, setPassword ] = useState('');
const [ error, setError ] = useState<string | null>(null);
const [ info, setInfo ] = useState<string | null>(null);
const [ submitting, setSubmitting ] = useState(false);
const [ loginTurnstileToken, setLoginTurnstileToken ] = useState('');
const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0);
const submitTimeRef = useRef(0);
const loginImages: Record<string, string> = ((GetConfigurationValue<Record<string, unknown>>('loginview', {})?.['images']) as Record<string, string>) ?? {};
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
const sun = interpolate(loginImages['sun'] || GetConfigurationValue<string>('login_sun', ''));
const drape = interpolate(loginImages['drape'] || GetConfigurationValue<string>('login_drape', ''));
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
useEffect(() =>
{
// eslint-disable-next-line no-console
console.info('[LoginView] resolved background assets', {
'asset.url': GetConfigurationValue<string>('asset.url', ''),
login_background: background,
'login_background.colour': backgroundColor,
login_sun: sun,
login_drape: drape,
login_left: left,
login_right: right,
'login_right.repeat': rightRepeat
});
}, [ background, backgroundColor, sun, drape, left, right, rightRepeat ]);
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
const turnstileSiteKey = GetConfigurationValue<string>('login.turnstile.sitekey', '');
const rawTurnstileEnabled = GetConfigurationValue<unknown>('login.turnstile.enabled', false);
const turnstileEnabled = (rawTurnstileEnabled === true
|| rawTurnstileEnabled === 'true'
|| rawTurnstileEnabled === 1
|| rawTurnstileEnabled === '1') && !!turnstileSiteKey;
useEffect(() =>
{
// eslint-disable-next-line no-console
console.info('[LoginView] turnstile config', {
rawTurnstileEnabled,
turnstileEnabled,
turnstileSiteKey: turnstileSiteKey ? (turnstileSiteKey.slice(0, 6) + '…') : '(empty)'
});
}, [ rawTurnstileEnabled, turnstileEnabled, turnstileSiteKey ]);
const resetLoginTurnstile = useCallback(() =>
{
setLoginTurnstileToken('');
setLoginTurnstileResetSignal(prev => prev + 1);
}, []);
// Clear error on mode change but keep the success notification so users
// returning to the login form can read it (e.g. "Account created").
// Reset the login captcha only when we're actually on the login form.
useEffect(() =>
{
setError(null);
if(mode === 'login') resetLoginTurnstile();
}, [ mode, resetLoginTurnstile ]);
// Auto-dismiss the info notification after a few seconds so it doesn't
// hang around forever once the user has seen it.
useEffect(() =>
{
if(!info) return;
const timeout = window.setTimeout(() => setInfo(null), 8000);
return () => window.clearTimeout(timeout);
}, [ info ]);
const lockState = useMemo(() => readLock(), [ submitting ]);
const now = Date.now();
const isLocked = lockState.lockedUntil > now;
const recordFailure = useCallback(() =>
{
const state = readLock();
const currentNow = Date.now();
if(currentNow - state.firstAt > LOCK_WINDOW_MS)
{
writeLock({ attempts: 1, firstAt: currentNow, lockedUntil: 0 });
return;
}
const attempts = state.attempts + 1;
const lockedUntil = attempts >= MAX_ATTEMPTS ? currentNow + LOCK_DURATION_MS : 0;
writeLock({ attempts, firstAt: state.firstAt || currentNow, lockedUntil });
}, []);
const clearLock = useCallback(() =>
{
writeLock({ attempts: 0, firstAt: 0, lockedUntil: 0 });
}, []);
const postJson = useCallback(async (url: string, body: Record<string, unknown>) =>
{
const response = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'NitroLoginView'
},
body: JSON.stringify(body)
});
let payload: Record<string, unknown> = {};
try { payload = await response.json(); }
catch { /* ignore non-json responses */ }
return { ok: response.ok, status: response.status, payload };
}, []);
const handleLoginSubmit = useCallback(async (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
if(submitting) return;
const nowTs = Date.now();
if(nowTs - submitTimeRef.current < 1000) return;
submitTimeRef.current = nowTs;
const state = readLock();
if(state.lockedUntil > nowTs)
{
const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000);
setError(`Too many attempts. Try again in ${ remaining }s.`);
return;
}
if(!username.trim() || !password)
{
setError('Please enter both your Habbo name and password.');
return;
}
if(turnstileEnabled && !loginTurnstileToken)
{
setError('Please complete the security check.');
return;
}
setError(null);
setSubmitting(true);
try
{
const { ok, payload } = await postJson(loginUrl, {
username: username.trim(),
password,
turnstileToken: turnstileEnabled ? loginTurnstileToken : undefined
});
const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : '');
if(ok && ssoTicket)
{
clearLock();
onAuthenticated(ssoTicket);
return;
}
recordFailure();
const message = typeof payload.error === 'string' ? payload.error : 'Invalid Habbo name or password.';
setError(message);
resetLoginTurnstile();
}
catch(err)
{
recordFailure();
setError('Unable to reach the login service. Please try again.');
resetLoginTurnstile();
}
finally
{
setSubmitting(false);
}
}, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile ]);
// Register + forgot-password submit handlers receive the Turnstile token
// from the dialog (the dialog owns its own widget lifecycle), so the
// login widget underneath can't reset or overwrite it while the user
// is working on the modal.
const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) =>
{
if(turnstileEnabled && !body.turnstileToken)
{
setError('Please complete the security check.');
return;
}
setError(null);
setInfo(null);
setSubmitting(true);
try
{
const { ok, payload } = await postJson(registerUrl, {
username: body.username,
email: body.email,
password: body.password,
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined
});
if(ok)
{
const friendly = `Welcome aboard, ${ body.username }! Your account is ready — log in below with the password you just chose.`;
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
setMode('login');
setUsername(body.username);
setPassword('');
return;
}
setError(typeof payload.error === 'string' ? payload.error : 'Unable to create your account.');
onDialogReset();
}
catch
{
setError('Unable to reach the registration service.');
onDialogReset();
}
finally
{
setSubmitting(false);
}
}, [ turnstileEnabled, registerUrl, postJson ]);
const handleForgotSubmit = useCallback(async (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) =>
{
if(turnstileEnabled && !body.turnstileToken)
{
setError('Please complete the security check.');
return;
}
setError(null);
setInfo(null);
setSubmitting(true);
try
{
const { ok, payload } = await postJson(forgotUrl, {
email: body.email,
turnstileToken: turnstileEnabled ? body.turnstileToken : undefined
});
if(ok)
{
const friendly = 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).';
setInfo(typeof payload.message === 'string' ? payload.message : friendly);
setMode('login');
return;
}
setError(typeof payload.error === 'string' ? payload.error : 'Unable to send a reset email right now.');
onDialogReset();
}
catch
{
setError('Unable to reach the password reset service.');
onDialogReset();
}
finally
{
setSubmitting(false);
}
}, [ turnstileEnabled, forgotUrl, postJson ]);
return (
<div
className="nitro-login-view"
style={ backgroundColor ? { background: backgroundColor } : undefined }
>
{ background ? <div className="login-background login-layer" style={ { backgroundImage: `url(${ background })` } } /> : null }
{ sun ? <div className="login-sun login-layer" style={ { backgroundImage: `url(${ sun })` } } /> : null }
{ drape ? <div className="login-drape login-layer" style={ { backgroundImage: `url(${ drape })` } } /> : null }
{ left ? <div className="login-left login-layer" style={ { backgroundImage: `url(${ left })` } } /> : null }
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
{ right ? <div className="login-right login-layer" style={ { backgroundImage: `url(${ right })` } } /> : null }
<div className="login-stack">
<div className="nitro-login-card">
<div className="card-title">First time here?</div>
<div className="card-body register-card-body">
<span>Don't have a Habbo yet?</span>
<a onClick={ () => setMode('register') }>You can create one here</a>
</div>
</div>
<div className="nitro-login-card">
<div className="card-title">What's your Habbo called?</div>
<form className="card-body" onSubmit={ handleLoginSubmit } autoComplete="on">
<div className="field">
<label htmlFor="login-username">Name of your Habbo</label>
<input
id="login-username"
name="username"
autoComplete="username"
type="text"
maxLength={ 32 }
value={ username }
onChange={ e => setUsername(e.target.value) }
/>
</div>
<div className="field">
<label htmlFor="login-password">Password</label>
<input
id="login-password"
name="password"
autoComplete="current-password"
type="password"
maxLength={ 128 }
value={ password }
onChange={ e => setPassword(e.target.value) }
/>
</div>
{ turnstileEnabled && mode === 'login' &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setLoginTurnstileToken }
onExpire={ () => setLoginTurnstileToken('') }
onError={ () => setLoginTurnstileToken('') }
resetSignal={ loginTurnstileResetSignal }
/> }
{ error && <div className="error-line">{ error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button
type="submit"
className="ok-button"
disabled={ submitting || isLocked }
>OK</button>
</div>
<a className="forgot" onClick={ () => setMode('forgot') }>Forgotten your password?</a>
</form>
</div>
</div>
{ mode === 'register' &&
<RegisterDialog
onCancel={ () => setMode('login') }
onSubmit={ handleRegisterSubmit }
submitting={ submitting }
error={ error }
info={ info }
turnstileEnabled={ turnstileEnabled }
turnstileSiteKey={ turnstileSiteKey }
/> }
{ mode === 'forgot' &&
<ForgotDialog
onCancel={ () => setMode('login') }
onSubmit={ handleForgotSubmit }
submitting={ submitting }
error={ error }
info={ info }
turnstileEnabled={ turnstileEnabled }
turnstileSiteKey={ turnstileSiteKey }
/> }
</div>
);
};
interface DialogSharedProps
{
onCancel: () => void;
submitting: boolean;
error: string | null;
info: string | null;
turnstileEnabled: boolean;
turnstileSiteKey: string;
}
interface RegisterDialogProps extends DialogSharedProps
{
onSubmit: (body: { username: string; email: string; password: string; turnstileToken: string; }, onDialogReset: () => void) => void;
}
const RegisterDialog: FC<RegisterDialogProps> = props =>
{
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ username, setUsername ] = useState('');
const [ email, setEmail ] = useState('');
const [ password, setPassword ] = useState('');
const [ confirm, setConfirm ] = useState('');
const [ localError, setLocalError ] = useState<string | null>(null);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const resetWidget = useCallback(() =>
{
setTurnstileToken('');
setResetSignal(prev => prev + 1);
}, []);
const handle = (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
if(!username.trim() || !email.trim() || !password)
{
setLocalError('Please fill in every field.');
return;
}
if(password.length < 8)
{
setLocalError('Your password must be at least 8 characters.');
return;
}
if(password !== confirm)
{
setLocalError('Passwords do not match.');
return;
}
onSubmit({ username: username.trim(), email: email.trim(), password, turnstileToken }, resetWidget);
};
return (
<div className="nitro-login-modal">
<div className="dialog">
<div className="nitro-login-card">
<div className="card-title">
<span>Create a Habbo</span>
<span className="nitro-card-close-button" role="button" aria-label="Close" onClick={ onCancel } />
</div>
<form className="card-body" onSubmit={ handle } autoComplete="on">
<div className="field">
<label htmlFor="register-username">Habbo name</label>
<input id="register-username" type="text" maxLength={ 32 } autoComplete="username"
value={ username } onChange={ e => setUsername(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-email">Email</label>
<input id="register-email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-password">Password</label>
<input id="register-password" type="password" maxLength={ 128 } autoComplete="new-password"
value={ password } onChange={ e => setPassword(e.target.value) } />
</div>
<div className="field">
<label htmlFor="register-confirm">Confirm password</label>
<input id="register-confirm" type="password" maxLength={ 128 } autoComplete="new-password"
value={ confirm } onChange={ e => setConfirm(e.target.value) } />
</div>
{ turnstileEnabled &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setTurnstileToken }
onExpire={ () => setTurnstileToken('') }
onError={ () => setTurnstileToken('') }
resetSignal={ resetSignal }
/> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button type="submit" className="ok-button" disabled={ submitting }>Create account</button>
</div>
</form>
</div>
</div>
</div>
);
};
interface ForgotDialogProps extends DialogSharedProps
{
onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void;
}
const ForgotDialog: FC<ForgotDialogProps> = props =>
{
const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props;
const [ email, setEmail ] = useState('');
const [ localError, setLocalError ] = useState<string | null>(null);
const [ turnstileToken, setTurnstileToken ] = useState('');
const [ resetSignal, setResetSignal ] = useState(0);
const resetWidget = useCallback(() =>
{
setTurnstileToken('');
setResetSignal(prev => prev + 1);
}, []);
const handle = (event: FormEvent<HTMLFormElement>) =>
{
event.preventDefault();
setLocalError(null);
if(!email.trim())
{
setLocalError('Please enter your email address.');
return;
}
onSubmit({ email: email.trim(), turnstileToken }, resetWidget);
};
return (
<div className="nitro-login-modal">
<div className="dialog">
<div className="nitro-login-card">
<div className="card-title">
<span>Reset password</span>
<span className="nitro-card-close-button" role="button" aria-label="Close" onClick={ onCancel } />
</div>
<form className="card-body" onSubmit={ handle } autoComplete="on">
<div className="field">
<label htmlFor="forgot-email">Email address</label>
<input id="forgot-email" type="email" maxLength={ 120 } autoComplete="email"
value={ email } onChange={ e => setEmail(e.target.value) } />
</div>
{ turnstileEnabled &&
<TurnstileWidget
siteKey={ turnstileSiteKey }
size="compact"
onToken={ setTurnstileToken }
onExpire={ () => setTurnstileToken('') }
onError={ () => setTurnstileToken('') }
resetSignal={ resetSignal }
/> }
{ (localError || error) && <div className="error-line">{ localError || error }</div> }
{ info && <div className="info-line">{ info }</div> }
<div className="submit-row">
<button type="submit" className="ok-button" disabled={ submitting }>Send email</button>
</div>
</form>
</div>
</div>
</div>
);
};
+118
View File
@@ -0,0 +1,118 @@
import { FC, useEffect, useRef } from 'react';
declare global
{
interface Window
{
turnstile?: {
render: (container: string | HTMLElement, options: Record<string, unknown>) => string;
reset: (widgetId?: string) => void;
remove: (widgetId?: string) => void;
};
onTurnstileLoad?: () => void;
}
}
const SCRIPT_ID = 'cf-turnstile-script';
const SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
let scriptPromise: Promise<void> | null = null;
const loadTurnstileScript = (): Promise<void> =>
{
if(typeof window === 'undefined') return Promise.resolve();
if(window.turnstile) return Promise.resolve();
if(scriptPromise) return scriptPromise;
scriptPromise = new Promise<void>((resolve, reject) =>
{
const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
if(existing)
{
existing.addEventListener('load', () => resolve());
existing.addEventListener('error', () => reject(new Error('Turnstile failed to load')));
return;
}
const script = document.createElement('script');
script.id = SCRIPT_ID;
script.src = SCRIPT_SRC;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('Turnstile failed to load'));
document.head.appendChild(script);
});
return scriptPromise;
};
export interface TurnstileWidgetProps
{
siteKey: string;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact';
onToken: (token: string) => void;
onExpire?: () => void;
onError?: () => void;
resetSignal?: number;
}
export const TurnstileWidget: FC<TurnstileWidgetProps> = props =>
{
const { siteKey, theme = 'light', size = 'normal', onToken, onExpire, onError, resetSignal = 0 } = props;
const containerRef = useRef<HTMLDivElement | null>(null);
const widgetIdRef = useRef<string | null>(null);
useEffect(() =>
{
if(!siteKey || !containerRef.current) return;
let cancelled = false;
loadTurnstileScript()
.then(() =>
{
if(cancelled || !window.turnstile || !containerRef.current) return;
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme,
size,
callback: (token: string) => onToken(token),
'expired-callback': () => onExpire?.(),
'error-callback': () => onError?.()
});
})
.catch(err =>
{
console.error('[Turnstile] script load failed', err);
onError?.();
});
return () =>
{
cancelled = true;
if(widgetIdRef.current && window.turnstile)
{
try { window.turnstile.remove(widgetIdRef.current); } catch { }
widgetIdRef.current = null;
}
};
}, [ siteKey, theme, size ]);
useEffect(() =>
{
if(resetSignal <= 0) return;
if(widgetIdRef.current && window.turnstile)
{
try { window.turnstile.reset(widgetIdRef.current); } catch { }
}
}, [ resetSignal ]);
if(!siteKey) return null;
return <div ref={ containerRef } className="turnstile-slot" />;
};
@@ -1,5 +1,5 @@
import { FC } from 'react'; import { FC } from 'react';
import { GetRendererVersion, GetUIVersion, NotificationAlertItem } from '../../../../api'; import { GetConfigurationValue, GetRendererVersion, GetUIVersion, NotificationAlertItem } from '../../../../api';
import { Button, Column, Grid, LayoutNotificationAlertView, LayoutNotificationAlertViewProps, Text } from '../../../../common'; import { Button, Column, Grid, LayoutNotificationAlertView, LayoutNotificationAlertViewProps, Text } from '../../../../common';
interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewProps interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewProps
@@ -9,10 +9,11 @@ interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewP
export const NitroSystemAlertView: FC<NotificationDefaultAlertViewProps> = props => export const NitroSystemAlertView: FC<NotificationDefaultAlertViewProps> = props =>
{ {
const { title = 'Nitro', onClose = null, ...rest } = props; const { title = 'Nitro', onClose = null, classNames = [], ...rest } = props;
const adsEnabled = GetConfigurationValue<boolean>('show.google.ads', false);
return ( return (
<LayoutNotificationAlertView title={ title } onClose={ onClose } { ...rest }> <LayoutNotificationAlertView title={ title } onClose={ onClose } classNames={ [ 'nitro-alert-system', ...classNames ] } { ...rest }>
<Grid> <Grid>
<Column size={ 12 }> <Column size={ 12 }>
<Column alignItems="center" gap={ 0 }> <Column alignItems="center" gap={ 0 }>
@@ -23,6 +24,8 @@ export const NitroSystemAlertView: FC<NotificationDefaultAlertViewProps> = props
<Text><b>Renderer:</b> v{ GetRendererVersion() }</Text> <Text><b>Renderer:</b> v{ GetRendererVersion() }</Text>
<Column fullWidth gap={ 1 }> <Column fullWidth gap={ 1 }>
<Button fullWidth variant="success" onClick={ event => window.open('https://discord.nitrodev.co') }>Discord</Button> <Button fullWidth variant="success" onClick={ event => window.open('https://discord.nitrodev.co') }>Discord</Button>
{ adsEnabled &&
<Button fullWidth onClick={ () => window.dispatchEvent(new CustomEvent('ads:toggle')) }>Show Ad</Button> }
</Column> </Column>
</Column> </Column>
<div className="alertView_nitro-coolui-logo"></div> <div className="alertView_nitro-coolui-logo"></div>
@@ -35,7 +38,7 @@ export const NitroSystemAlertView: FC<NotificationDefaultAlertViewProps> = props
</Column> </Column>
</Column> </Column>
</Column> </Column>
</Grid> </Grid>
</LayoutNotificationAlertView> </LayoutNotificationAlertView>
); );
+32 -2
View File
@@ -1,6 +1,6 @@
import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer'; import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react'; import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaChevronDown, FaLanguage, FaQuestionCircle } from 'react-icons/fa'; import { FaChevronDown, FaLanguage, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa';
import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api'; import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api';
import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common'; import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common';
import { usePurse } from '../../hooks'; import { usePurse } from '../../hooks';
@@ -58,6 +58,33 @@ export const PurseView: FC<{}> = props => {
return () => window.clearTimeout(timeout); return () => window.clearTimeout(timeout);
}, [ isOpen ]); }, [ isOpen ]);
const handleLogout = useCallback(async (event: React.MouseEvent) =>
{
event.stopPropagation();
const logoutUrl = GetConfigurationValue<string>('login.logout.endpoint', '/api/auth/logout');
const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? '';
try
{
await fetch(logoutUrl, {
method: 'POST',
credentials: 'include',
keepalive: true,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'NitroPurseLogout'
},
body: JSON.stringify({ ssoTicket })
});
}
catch { /* best-effort — proceed with local logout regardless */ }
if(window.NitroConfig) window.NitroConfig['sso.ticket'] = '';
window.location.reload();
}, []);
if (!purse) return null; if (!purse) return null;
return ( return (
@@ -100,6 +127,9 @@ export const PurseView: FC<{}> = props => {
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--settings" onClick={ event => { event.stopPropagation(); CreateLinkEvent('user-settings/toggle'); } } title={ LocalizeText('widget.memenu.settings.title') }> <button type="button" className="nitro-purse__action-button nitro-purse__action-button--settings" onClick={ event => { event.stopPropagation(); CreateLinkEvent('user-settings/toggle'); } } title={ LocalizeText('widget.memenu.settings.title') }>
<i className="nitro-icon icon-cog" /> <i className="nitro-icon icon-cog" />
</button> </button>
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--logout" onClick={ handleLogout } title="Log out">
<FaSignOutAlt />
</button>
</div> </div>
</div> </div>
{ seasonalCurrencies.length > 0 && { seasonalCurrencies.length > 0 &&
@@ -1,6 +1,6 @@
import { AvatarFigurePartType, GetAvatarRenderManager, GetSessionDataManager, RedeemItemClothingComposer, RoomObjectCategory, UserFigureComposer } from '@nitrots/nitro-renderer'; import { AvatarFigurePartType, GetAvatarRenderManager, GetSessionDataManager, RedeemItemClothingComposer, RoomObjectCategory, UserFigureComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { FurniCategory, GetFurnitureDataForRoomObject, LocalizeText, SendMessageComposer } from '../../../../../api'; import { BuildPurchasableClothingFigure, GetFurnitureDataForRoomObject, LocalizeText, SendMessageComposer } from '../../../../../api';
import { Button, Column, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../../common'; import { Button, Column, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../../common';
import { useRoom } from '../../../../../hooks'; import { useRoom } from '../../../../../hooks';
@@ -41,22 +41,18 @@ export const PurchasableClothingConfirmView: FC<PurchasableClothingConfirmViewPr
{ {
const furniData = GetFurnitureDataForRoomObject(roomSession.roomId, objectId, RoomObjectCategory.FLOOR); const furniData = GetFurnitureDataForRoomObject(roomSession.roomId, objectId, RoomObjectCategory.FLOOR);
if(furniData) if(furniData && furniData.customParams && furniData.customParams.length)
{ {
switch(furniData.specialType) const setIds = furniData.customParams.split(',')
.map(part => parseInt(part))
.filter(id => !isNaN(id));
for(const setId of setIds)
{ {
case FurniCategory.FIGURE_PURCHASABLE_SET: if(GetAvatarRenderManager().isValidFigureSetForGender(setId, gender)) validSets.push(setId);
mode = MODE_PURCHASABLE_CLOTHING;
const setIds = furniData.customParams.split(',').map(part => parseInt(part));
for(const setId of setIds)
{
if(GetAvatarRenderManager().isValidFigureSetForGender(setId, gender)) validSets.push(setId);
}
break;
} }
if(validSets.length) mode = MODE_PURCHASABLE_CLOTHING;
} }
} }
@@ -68,7 +64,7 @@ export const PurchasableClothingConfirmView: FC<PurchasableClothingConfirmViewPr
} }
setGender(gender); setGender(gender);
setNewFigure(GetAvatarRenderManager().getFigureStringWithFigureIds(figure, gender, validSets)); setNewFigure(BuildPurchasableClothingFigure(figure, validSets));
// if owns clothing, change to it // if owns clothing, change to it
@@ -6,7 +6,6 @@ import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStora
import { Text } from '../../../../common'; import { Text } from '../../../../common';
import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks'; import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks';
import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi'; import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
export const RoomToolsWidgetView: FC<{}> = props => { export const RoomToolsWidgetView: FC<{}> = props => {
const [areBubblesMuted, setAreBubblesMuted] = useState(false); const [areBubblesMuted, setAreBubblesMuted] = useState(false);
@@ -16,7 +15,6 @@ export const RoomToolsWidgetView: FC<{}> = props => {
const [roomTags, setRoomTags] = useState<string[]>(null); const [roomTags, setRoomTags] = useState<string[]>(null);
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false); const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false);
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]); const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]);
const [plugins, setPlugins] = useState<INitroPlugin[]>([]); const [plugins, setPlugins] = useState<INitroPlugin[]>([]);
const { navigatorData = null } = useNavigator(); const { navigatorData = null } = useNavigator();
@@ -81,11 +79,10 @@ export const RoomToolsWidgetView: FC<{}> = props => {
const onChangeRoomHistory = (roomId: number, roomName: string) => { const onChangeRoomHistory = (roomId: number, roomName: string) => {
let newStorage = JSON.parse(window.localStorage.getItem('nitro.room.history') || '[]'); let newStorage = JSON.parse(window.localStorage.getItem('nitro.room.history') || '[]');
if (newStorage.some((room: { roomId: number }) => room.roomId === roomId)) return;
newStorage = newStorage.filter((room: { roomId: number }) => room.roomId !== roomId); if (newStorage.length >= 10) newStorage.shift();
newStorage = [ ...newStorage, { roomId, roomName } ]; newStorage = [...newStorage, { roomId, roomName }];
if (newStorage.length > 10) newStorage = newStorage.slice(-10);
setRoomHistory(newStorage); setRoomHistory(newStorage);
SetLocalStorage('nitro.room.history', newStorage); SetLocalStorage('nitro.room.history', newStorage);
@@ -102,55 +99,48 @@ export const RoomToolsWidgetView: FC<{}> = props => {
}); });
useEffect(() => { useEffect(() => {
if(roomName || roomOwner || (roomTags && roomTags.length)) setIsOpen(true); setIsOpen(true);
const timeout = setTimeout(() => setIsOpen(false), 5000);
return () => clearTimeout(timeout);
}, [roomName, roomOwner, roomTags]); }, [roomName, roomOwner, roomTags]);
useEffect(() => {
if(!isCollapsed && (roomName || roomOwner || (roomTags && roomTags.length))) setIsOpen(true);
}, [ isCollapsed, roomName, roomOwner, roomTags ]);
useEffect(() => { useEffect(() => {
setRoomHistory(JSON.parse(window.localStorage.getItem('nitro.room.history') || '[]')); setRoomHistory(JSON.parse(window.localStorage.getItem('nitro.room.history') || '[]'));
}, []); }, []);
return ( useEffect(() => {
<div className="nitro-room-tools-container"> const handleTabClose = () => {
<div className="nitro-room-tools-rail"> window.localStorage.removeItem('nitro.room.history');
<button type="button" className={ `nitro-room-tools-toggle ${ isCollapsed ? 'is-collapsed' : 'is-open' }` } onClick={ () => setIsCollapsed(value => !value) } title={ isCollapsed ? 'Apri strumenti stanza' : 'Chiudi strumenti stanza' }> };
{ isCollapsed ? <FaChevronRight className="text-[11px]" /> : <FaChevronLeft className="text-[11px]" /> } window.addEventListener('beforeunload', handleTabClose);
</button> return () => window.removeEventListener('beforeunload', handleTabClose);
<AnimatePresence initial={ false }> }, []);
{ !isCollapsed &&
<motion.div
initial={ { opacity: 0, x: -10, scale: 0.96 } }
animate={ { opacity: 1, x: 0, scale: 1 } }
exit={ { opacity: 0, x: -10, scale: 0.96 } }
transition={ { duration: 0.26, ease: [ 0.16, 1, 0.3, 1 ] } }
className="flex flex-col items-center justify-center p-2 nitro-room-tools">
<div className="cursor-pointer nitro-icon icon-cog" title={LocalizeText('room.settings.button.text')} onClick={() => handleToolClick('settings')} />
<div className={classNames('cursor-pointer', 'nitro-icon', (!isZoomedIn && 'icon-zoom-less'), (isZoomedIn && 'icon-zoom-more'))} title={LocalizeText('room.zoom.button.text')} onClick={() => handleToolClick('zoom')} />
<div className="cursor-pointer nitro-icon icon-chat-history" title={LocalizeText('room.chathistory.button.text')} onClick={() => handleToolClick('chat_history')} />
<div className={classNames('cursor-pointer', 'nitro-icon', (areBubblesMuted ? 'icon-chat-disablebubble' : 'icon-chat-enablebubble'))} title={areBubblesMuted ? LocalizeText('room.unmute.button.text') : LocalizeText('room.mute.button.text')} onClick={() => handleToolClick('hiddenbubbles')} />
{navigatorData.canRate && ( return (
<div className="cursor-pointer nitro-icon icon-like-room" title={LocalizeText('room.like.button.text')} onClick={() => handleToolClick('like_room')} /> <div className="flex space-x-2 nitro-room-tools-container">
)} <div className="flex flex-col items-center justify-center p-2 nitro-room-tools">
<div className="cursor-pointer nitro-icon icon-room-link" title={LocalizeText('navigator.embed.caption')} onClick={() => handleToolClick('toggle_room_link')} /> <div className="cursor-pointer nitro-icon icon-cog" title={LocalizeText('room.settings.button.text')} onClick={() => handleToolClick('settings')} />
<div className="cursor-pointer nitro-icon icon-room-history-enabled" title={LocalizeText('room.history.button.tooltip')} onClick={() => handleToolClick('room_history')} /> <div className={classNames('cursor-pointer', 'nitro-icon', (!isZoomedIn && 'icon-zoom-less'), (isZoomedIn && 'icon-zoom-more'))} title={LocalizeText('room.zoom.button.text')} onClick={() => handleToolClick('zoom')} />
{plugins.map(plugin => ( <div className="cursor-pointer nitro-icon icon-chat-history" title={LocalizeText('room.chathistory.button.text')} onClick={() => handleToolClick('chat_history')} />
<div <div className={classNames('cursor-pointer', 'nitro-icon', (areBubblesMuted ? 'icon-chat-disablebubble' : 'icon-chat-enablebubble'))} title={areBubblesMuted ? LocalizeText('room.unmute.button.text') : LocalizeText('room.mute.button.text')} onClick={() => handleToolClick('hiddenbubbles')} />
key={plugin.name}
className={`cursor-pointer nitro-icon ${plugin.icon || 'icon-cog'}`} {navigatorData.canRate && (
title={plugin.label} <div className="cursor-pointer nitro-icon icon-like-room" title={LocalizeText('room.like.button.text')} onClick={() => handleToolClick('like_room')} />
onClick={() => plugin.onOpen()} )}
/> <div className="cursor-pointer nitro-icon icon-room-link" title={LocalizeText('navigator.embed.caption')} onClick={() => handleToolClick('toggle_room_link')} />
))} <div className="cursor-pointer nitro-icon icon-room-history-enabled" title={LocalizeText('room.history.button.tooltip')} onClick={() => handleToolClick('room_history')} />
</motion.div> } {plugins.map(plugin => (
</AnimatePresence> <div
key={plugin.name}
className={`cursor-pointer nitro-icon ${plugin.icon || 'icon-cog'}`}
title={plugin.label}
onClick={() => plugin.onOpen()}
/>
))}
</div> </div>
<div className="flex flex-col justify-center nitro-room-tools-side-container"> <div className="flex flex-col justify-center">
<AnimatePresence> <AnimatePresence>
{(!isCollapsed && (isOpen || !!roomName || !!roomOwner || !!(roomTags && roomTags.length))) && ( {isOpen && (
<motion.div initial={{ x: -100 }} animate={{ x: 0 }} exit={{ x: -100 }} transition={{ duration: 0.3 }}> <motion.div initial={{ x: -100 }} animate={{ x: 0 }} exit={{ x: -100 }} transition={{ duration: 0.3 }}>
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<div className="flex flex-col px-3 py-2 rounded nitro-room-tools-info"> <div className="flex flex-col px-3 py-2 rounded nitro-room-tools-info">
@@ -171,7 +161,7 @@ export const RoomToolsWidgetView: FC<{}> = props => {
</div> </div>
</motion.div> </motion.div>
)} )}
{(!isCollapsed && isOpenHistory) && ( {isOpenHistory && (
<motion.div initial={{ x: -100 }} animate={{ x: 0 }} exit={{ x: -100 }} transition={{ duration: 0.3 }} className="nitro-room-tools-history"> <motion.div initial={{ x: -100 }} animate={{ x: 0 }} exit={{ x: -100 }} transition={{ duration: 0.3 }} className="nitro-room-tools-history">
<div className="flex flex-col px-3 py-2 rounded nitro-room-history"> <div className="flex flex-col px-3 py-2 rounded nitro-room-history">
{roomHistory.map(history => ( {roomHistory.map(history => (
+1 -1
View File
@@ -11,7 +11,7 @@ export const ToolbarItemView = forwardRef<HTMLDivElement, PropsWithChildren<{
<div <div
ref={ ref } ref={ ref }
className={ classNames( className={ classNames(
'relative h-[32px] w-[32px] shrink-0 cursor-pointer bg-center bg-no-repeat transition-transform duration-200 ease-out hover:-translate-y-[1px] active:translate-y-0', 'cursor-pointer relative',
`nitro-icon icon-${ icon }`, `nitro-icon icon-${ icon }`,
className className
) } ) }
+26 -20
View File
@@ -1,9 +1,8 @@
import { CreateLinkEvent, GetRoomEngine, GetSessionDataManager, RoomObjectCategory } from '@nitrots/nitro-renderer'; import { CreateLinkEvent, GetRoomEngine, GetSessionDataManager, MouseEventType, RoomObjectCategory } from '@nitrots/nitro-renderer';
import { Dispatch, FC, PropsWithChildren, SetStateAction, useEffect, useRef } from 'react'; import { Dispatch, FC, PropsWithChildren, SetStateAction, useEffect, useRef } from 'react';
import { DispatchUiEvent, GetConfigurationValue, GetRoomSession, GetUserProfile, LocalizeText } from '../../api'; import { DispatchUiEvent, GetConfigurationValue, GetRoomSession, GetUserProfile, LocalizeText } from '../../api';
import { LayoutItemCountView } from '../../common'; import { Flex, LayoutItemCountView } from '../../common';
import { GuideToolEvent } from '../../events'; import { GuideToolEvent } from '../../events';
import { ToolbarItemView } from './ToolbarItemView';
export const ToolbarMeView: FC<PropsWithChildren<{ export const ToolbarMeView: FC<PropsWithChildren<{
useGuideTool: boolean; useGuideTool: boolean;
@@ -11,8 +10,8 @@ export const ToolbarMeView: FC<PropsWithChildren<{
setMeExpanded: Dispatch<SetStateAction<boolean>>; setMeExpanded: Dispatch<SetStateAction<boolean>>;
}>> = props => }>> = props =>
{ {
const { useGuideTool = false, unseenAchievementCount = 0, children = null } = props; const { useGuideTool = false, unseenAchievementCount = 0, setMeExpanded = null, children = null, ...rest } = props;
const elementRef = useRef<HTMLDivElement>(null); const elementRef = useRef<HTMLDivElement>();
useEffect(() => useEffect(() =>
{ {
@@ -23,22 +22,29 @@ export const ToolbarMeView: FC<PropsWithChildren<{
GetRoomEngine().selectRoomObject(roomSession.roomId, roomSession.ownRoomIndex, RoomObjectCategory.UNIT); GetRoomEngine().selectRoomObject(roomSession.roomId, roomSession.ownRoomIndex, RoomObjectCategory.UNIT);
}, []); }, []);
useEffect(() =>
{
const onClick = (event: MouseEvent) => setMeExpanded(false);
document.addEventListener('click', onClick);
return () => document.removeEventListener(MouseEventType.MOUSE_CLICK, onClick);
}, [ setMeExpanded ]);
return ( return (
<div className="w-fit max-w-[min(calc(100vw-16px),520px)] rounded-[12px] border border-white/8 bg-[rgba(10,10,12,0.58)] px-[10px] py-[7px] shadow-[0_10px_24px_rgba(0,0,0,0.2)]" ref={ elementRef }> <Flex alignItems="center" className="absolute bottom-[60px] left-[33px] bg-[rgba(20,20,20,.95)] border border-[solid] border-[#101010] [box-shadow:inset_2px_2px_rgba(255,255,255,.1),inset_-2px_-2px_rgba(255,255,255,.1)] rounded-[$border-radius] p-2" gap={ 2 } innerRef={ elementRef }>
<div className="flex items-center gap-[8px] overflow-x-auto overflow-y-visible whitespace-nowrap"> { (GetConfigurationValue('guides.enabled') && useGuideTool) &&
{ (GetConfigurationValue('guides.enabled') && useGuideTool) && <div className="navigation-item relative nitro-icon icon-me-helper-tool cursor-pointer" onClick={ event => DispatchUiEvent(new GuideToolEvent(GuideToolEvent.TOGGLE_GUIDE_TOOL)) } /> }
<ToolbarItemView icon="me-helper-tool" onClick={ event => DispatchUiEvent(new GuideToolEvent(GuideToolEvent.TOGGLE_GUIDE_TOOL)) } title={ LocalizeText('guide.help.button.label') } /> } <div className="navigation-item relative nitro-icon icon-me-achievements cursor-pointer" onClick={ event => CreateLinkEvent('achievements/toggle') }>
<ToolbarItemView icon="me-achievements" onClick={ event => CreateLinkEvent('achievements/toggle') } title={ LocalizeText('toolbar.icon.label.achievements') }> { (unseenAchievementCount > 0) &&
{ (unseenAchievementCount > 0) && <LayoutItemCountView count={ unseenAchievementCount } /> }
<LayoutItemCountView count={ unseenAchievementCount } /> }
</ToolbarItemView>
<ToolbarItemView icon="me-profile" onClick={ event => GetUserProfile(GetSessionDataManager().userId) } title={ LocalizeText('toolbar.icon.label.memenu') } />
<ToolbarItemView icon="me-rooms" onClick={ event => CreateLinkEvent('navigator/search/myworld_view') } title={ LocalizeText('navigator.myworlds') } />
<ToolbarItemView icon="me-clothing" onClick={ event => CreateLinkEvent('avatar-editor/toggle') } title={ LocalizeText('widget.memenu.settings.avatar') } />
<ToolbarItemView icon="me-settings" onClick={ event => CreateLinkEvent('user-settings/toggle') } title={ LocalizeText('widget.memenu.settings.title') } />
<ToolbarItemView icon="me-forums" onClick={ event => CreateLinkEvent('groupforum/toggle') } title={ LocalizeText('toolbar.icon.label.forums') } />
{ children }
</div> </div>
</div> <div className="navigation-item relative nitro-icon icon-me-profile cursor-pointer" onClick={ event => GetUserProfile(GetSessionDataManager().userId) } />
<div className="navigation-item relative nitro-icon icon-me-rooms cursor-pointer" onClick={ event => CreateLinkEvent('navigator/search/myworld_view') } />
<div className="navigation-item relative nitro-icon icon-me-clothing cursor-pointer" onClick={ event => CreateLinkEvent('avatar-editor/toggle') } />
<div className="navigation-item relative nitro-icon icon-me-settings cursor-pointer" onClick={ event => CreateLinkEvent('user-settings/toggle') } />
<div className="navigation-item relative nitro-icon icon-me-forums cursor-pointer" onClick={ event => CreateLinkEvent('groupforum/toggle') } title={ LocalizeText('toolbar.icon.label.forums') } />
{ children }
</Flex>
); );
}; };
+14 -5
View File
@@ -1852,23 +1852,23 @@ body {
} }
.avatar-parts { .avatar-parts {
border: none !important; border: 1px solid #c5c3c0 !important;
position: relative; position: relative;
aspect-ratio: 1; aspect-ratio: 1;
width: 100%; width: 100%;
max-width: 42px; max-width: 42px;
border-radius: 2rem !important; border-radius: 0.3rem !important;
overflow: hidden !important; overflow: hidden !important;
background-color: transparent; background-color: #e9e8e4;
&:hover { &:hover {
box-shadow: 0 0 0 3px #dbdad5 !important; box-shadow: inset 0 0 0 3px #dbdad5 !important;
background-color: #cecdc8 !important; background-color: #cecdc8 !important;
} }
&:active, &:active,
&.part-selected { &.part-selected {
box-shadow: 0 0 0 3px #c5c3c0 !important; box-shadow: inset 0 0 0 3px #c5c3c0 !important;
background-color: #b1b1b1 !important; background-color: #b1b1b1 !important;
} }
} }
@@ -1949,6 +1949,15 @@ body {
background-position: center; background-position: center;
background-size: 22px 22px; background-size: 22px 22px;
} }
.tab-misc {
width: 34px;
height: 22px;
background-image: url('@/assets/images/wardrobe/misc.png');
background-repeat: no-repeat;
background-position: center;
background-size: 24px 22px;
}
} }
.nitro-wired__variable-picker-portal { .nitro-wired__variable-picker-portal {
+261
View File
@@ -0,0 +1,261 @@
/* ─── Classic Login View ─────────────────────────────────────────────────
Port of the old Nitro HotelView background layering, used exclusively by
the login screen. Assets are driven by ui-config.json:
loginview.images.background → .login-background
loginview.images.background.colour → .nitro-login-view base colour
loginview.images.sun → .login-sun
loginview.images.drape → .login-drape
loginview.images.left → .login-left
loginview.images.right → .login-right
loginview.images.right.repeat → .login-right-repeat
Class names are deliberately prefixed so HotelView.css rules
(.left { left: 18vw !important } etc.) cannot clobber us.
--------------------------------------------------------------------- */
.nitro-login-view {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #6eadc8;
z-index: 100;
}
.nitro-login-view .login-layer {
position: absolute;
background-repeat: no-repeat;
pointer-events: none;
}
.nitro-login-view .login-background {
top: 0;
left: 0;
width: 100%;
height: 100%;
background-repeat: repeat-x;
background-position: center top;
}
.nitro-login-view .login-sun {
left: 50%;
transform: translateX(-50%);
width: 600px;
height: 600px;
background-size: contain;
background-position: center top;
}
.nitro-login-view .login-drape {
top: 0;
left: 0;
width: 190px;
height: 220px;
z-index: 3;
}
.nitro-login-view .login-left {
bottom: 0;
left: 0;
width: 100%;
height: 100%;
background-position: left bottom;
background-size: auto;
background-repeat: no-repeat;
}
.nitro-login-view .login-right-repeat {
top: 0;
right: 0;
width: 400px;
height: 100%;
background-repeat: repeat-y;
background-position: right top;
}
.nitro-login-view .login-right {
bottom: 0;
right: 0;
width: 400px;
height: 100%;
background-position: right bottom;
}
/* ─── Foreground Login Card Stack ───────────────────────────────────── */
.nitro-login-view .login-stack {
position: absolute;
top: 50%;
right: 8vw;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 18px;
width: 260px;
z-index: 50;
pointer-events: auto;
}
.nitro-login-card {
background: #a2bfd1;
border: 2px solid #3f6a85;
border-radius: 8px;
padding: 12px 14px;
color: #0a2e45;
font-family: Ubuntu, 'Helvetica Neue', Arial, sans-serif;
box-shadow: inset 0 2px rgba(255, 255, 255, 0.35), 0 4px 6px rgba(0, 0, 0, 0.25);
}
.nitro-login-card .card-title {
position: relative;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: #ffffff;
background: #3f6a85;
padding: 4px 26px;
margin: -12px -14px 10px -14px;
border-radius: 6px 6px 0 0;
font-size: 13px;
letter-spacing: 0.5px;
text-shadow: 0 1px rgba(0, 0, 0, 0.35);
}
.nitro-login-card .card-title .nitro-card-close-button {
position: absolute;
top: 50%;
right: 6px;
transform: translateY(-50%);
cursor: pointer;
}
.nitro-login-card .card-body {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 12px;
}
.nitro-login-card .field {
display: flex;
flex-direction: column;
gap: 4px;
}
.nitro-login-card .field label {
font-size: 11px;
color: #0a2e45;
font-weight: 600;
}
.nitro-login-card .field input {
width: 100%;
padding: 6px 8px;
border-radius: 20px;
border: 1px solid #7595ac;
background: #ffffff;
color: #0a2e45;
font-size: 12px;
outline: none;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
.nitro-login-card .field input:focus {
border-color: #3f6a85;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1), 0 0 0 2px rgba(63, 106, 133, 0.3);
}
.nitro-login-card .submit-row {
display: flex;
justify-content: center;
margin-top: 2px;
}
.nitro-login-card button.ok-button {
cursor: pointer;
background: #ffffff;
border: 1px solid #3f6a85;
border-radius: 4px;
padding: 3px 16px;
font-size: 12px;
font-weight: 700;
color: #0a2e45;
box-shadow: inset 0 1px rgba(255, 255, 255, 0.8), 0 1px rgba(0, 0, 0, 0.15);
}
.nitro-login-card button.ok-button:hover {
background: #e9f1f7;
}
.nitro-login-card button.ok-button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.nitro-login-card .forgot {
display: block;
text-align: center;
margin-top: 6px;
font-size: 11px;
color: #134b6e;
text-decoration: underline;
cursor: pointer;
}
.nitro-login-card .error-line {
color: #a81a12;
background: #fde6e4;
border: 1px solid #e0a7a2;
border-radius: 4px;
padding: 4px 6px;
font-size: 11px;
text-align: center;
}
.nitro-login-card .info-line {
color: #0a4d2e;
background: #e5f5ec;
border: 1px solid #a4d4b8;
border-radius: 4px;
padding: 4px 6px;
font-size: 11px;
text-align: center;
}
.nitro-login-card .register-card-body a {
color: #134b6e;
text-decoration: underline;
cursor: pointer;
font-weight: 600;
}
.nitro-login-card .turnstile-slot {
display: flex;
justify-content: center;
margin-top: 4px;
min-height: 65px;
}
.nitro-login-card .turnstile-slot iframe {
max-width: 100%;
}
/* Modal overlay used for register + forgot password dialogs */
.nitro-login-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.nitro-login-modal .dialog {
width: 320px;
max-width: calc(100% - 40px);
}
@@ -19,7 +19,7 @@
min-width: auto; min-width: auto;
} }
} }
&.nitro-alert-credits { &.nitro-alert-credits {
width: 370px; width: 370px;
.notification-text { .notification-text {
@@ -34,6 +34,19 @@
min-width: 225px; min-width: 225px;
} }
} }
&.nitro-alert-system {
width: auto;
min-width: 260px;
max-width: 90vw;
min-height: auto;
max-height: none;
height: auto;
.notification-text {
min-width: auto;
}
}
} }
.nitro-notification-bubble { .nitro-notification-bubble {
+56
View File
@@ -0,0 +1,56 @@
.tb-icon {
opacity: 1;
cursor: pointer;
transition: transform 0.15s ease;
&:hover {
transform: translateY(-2px);
}
&:active {
transform: translateY(0);
}
}
.tb-toggle {
display: flex;
width: 32px;
height: 32px;
flex-shrink: 0;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 9px;
background: rgba(18, 16, 14, 0.80);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5);
transition: background 0.15s, border-color 0.15s;
&:hover {
background: rgba(30, 26, 20, 0.88);
border-color: rgba(255, 255, 255, 0.13);
}
}
.tb-bar-scroll {
overflow-x: auto;
overflow-y: visible;
flex-wrap: nowrap;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.tb-open-shell {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
@@ -71,6 +71,10 @@ const useAvatarEditorState = () =>
setMaxPaletteCount(partItem.maxPaletteCount || 1); setMaxPaletteCount(partItem.maxPaletteCount || 1);
selectPart(setType, partId); selectPart(setType, partId);
// Pet (pt) and Misc (mc) cannot be equipped together — equipping one unequips the other.
if(setType === AvatarFigurePartType.PET) selectPart(AvatarFigurePartType.MISC, -1);
else if(setType === AvatarFigurePartType.MISC) selectPart(AvatarFigurePartType.PET, -1);
}, [ activeModel, selectPart ]); }, [ activeModel, selectPart ]);
const selectEditorColor = useCallback((setType: string, paletteId: number, colorId: number) => const selectEditorColor = useCallback((setType: string, paletteId: number, colorId: number) =>
@@ -316,6 +320,7 @@ const useAvatarEditorState = () =>
newAvatarModels[AvatarEditorFigureCategory.TORSO] = [ AvatarFigurePartType.CHEST, AvatarFigurePartType.CHEST_PRINT, AvatarFigurePartType.COAT_CHEST, AvatarFigurePartType.CHEST_ACCESSORY ].map(setType => buildCategory(setType, buildModeDefault)); newAvatarModels[AvatarEditorFigureCategory.TORSO] = [ AvatarFigurePartType.CHEST, AvatarFigurePartType.CHEST_PRINT, AvatarFigurePartType.COAT_CHEST, AvatarFigurePartType.CHEST_ACCESSORY ].map(setType => buildCategory(setType, buildModeDefault));
newAvatarModels[AvatarEditorFigureCategory.LEGS] = [ AvatarFigurePartType.LEGS, AvatarFigurePartType.SHOES, AvatarFigurePartType.WAIST_ACCESSORY ].map(setType => buildCategory(setType, buildModeDefault)); newAvatarModels[AvatarEditorFigureCategory.LEGS] = [ AvatarFigurePartType.LEGS, AvatarFigurePartType.SHOES, AvatarFigurePartType.WAIST_ACCESSORY ].map(setType => buildCategory(setType, buildModeDefault));
newAvatarModels[AvatarEditorFigureCategory.PETS] = [ AvatarFigurePartType.PET ].map(setType => buildCategory(setType)).filter(Boolean); newAvatarModels[AvatarEditorFigureCategory.PETS] = [ AvatarFigurePartType.PET ].map(setType => buildCategory(setType)).filter(Boolean);
newAvatarModels[AvatarEditorFigureCategory.MISC] = [ AvatarFigurePartType.MISC ].map(setType => buildCategory(setType)).filter(Boolean);
newAvatarModels[AvatarEditorFigureCategory.NFT] = [ newAvatarModels[AvatarEditorFigureCategory.NFT] = [
AvatarFigurePartType.HEAD, AvatarFigurePartType.HEAD,
AvatarFigurePartType.HAIR, AvatarFigurePartType.HAIR,
+2 -2
View File
@@ -609,7 +609,7 @@ const useCatalogState = () =>
if(!products.length) continue; if(!products.length) continue;
const purchasableOffer = new Offer(offer.offerId, offer.localizationId, offer.rent, offer.priceCredits, offer.priceActivityPoints, offer.priceActivityPointsType, offer.giftable, offer.clubLevel, products, offer.bundlePurchaseAllowed); const purchasableOffer = new Offer(offer.offerId, offer.localizationId, offer.rent, offer.priceCredits, offer.priceActivityPoints, offer.priceActivityPointsType, offer.giftable, offer.clubLevel, products, offer.bundlePurchaseAllowed, offer.itemIds, offer.haveOffer);
cacheResolvedOffer(purchasableOffer); cacheResolvedOffer(purchasableOffer);
@@ -680,7 +680,7 @@ const useCatalogState = () =>
products.push(new Product(product.productType, product.furniClassId, product.extraParam, product.productCount, productData, furnitureData, product.uniqueLimitedItem, product.uniqueLimitedSeriesSize, product.uniqueLimitedItemsLeft)); products.push(new Product(product.productType, product.furniClassId, product.extraParam, product.productCount, productData, furnitureData, product.uniqueLimitedItem, product.uniqueLimitedSeriesSize, product.uniqueLimitedItemsLeft));
} }
const offer = new Offer(offerData.offerId, offerData.localizationId, offerData.rent, offerData.priceCredits, offerData.priceActivityPoints, offerData.priceActivityPointsType, offerData.giftable, offerData.clubLevel, products, offerData.bundlePurchaseAllowed); const offer = new Offer(offerData.offerId, offerData.localizationId, offerData.rent, offerData.priceCredits, offerData.priceActivityPoints, offerData.priceActivityPointsType, offerData.giftable, offerData.clubLevel, products, offerData.bundlePurchaseAllowed, offerData.itemIds, offerData.haveOffer);
cacheResolvedOffer(offer); cacheResolvedOffer(offer);
const matchingNodes = getNodesByOfferId(offer.offerId, true) || getNodesByOfferId(offer.offerId); const matchingNodes = getNodesByOfferId(offer.offerId, true) || getNodesByOfferId(offer.offerId);
+4
View File
@@ -16,6 +16,8 @@ import './css/friends/FriendsView.css';
import './css/hotelview/HotelView.css'; import './css/hotelview/HotelView.css';
import './css/login/LoginView.css';
import './css/icons/icons.css'; import './css/icons/icons.css';
@@ -34,6 +36,8 @@ import './css/room/RoomWidgets.css';
import './css/slider.css'; import './css/slider.css';
import './css/toolbar/ToolBar.css';
import './css/widgets/FurnitureWidgets.css'; import './css/widgets/FurnitureWidgets.css';
createRoot(document.getElementById('root')).render(<App />); createRoot(document.getElementById('root')).render(<App />);
+1 -1
View File
@@ -18,7 +18,7 @@ export default defineConfig({
}, },
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3000', target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096',
changeOrigin: true, changeOrigin: true,
} }
} }