diff --git a/public/UITexts.example b/public/UITexts.example index 75ec9d5..bb8779b 100644 --- a/public/UITexts.example +++ b/public/UITexts.example @@ -1,57 +1,113 @@ ο»Ώ{ - "friendlist.search": "Zoek vrienden", - "widget.chooser.checkall": "Selecteer meubels", - "widget.chooser.btn.pickall": "pak de geselecteerde items op!", - "widget.settings.general": "Standaard", - "widget.settings.general.title": "Pas de standaard nitro settings aan", + "friendlist.search": "Search friends", + "purse.seasonal.currency.101": "cash", + "widget.chooser.checkall": "Select furniture", + "widget.chooser.btn.pickall": "pick up selected items!", + "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.interface": "Interface", - "widget.settings.interface.title": "Pas de settings aan voor de interface", - "widget.settings.interface.fps.automatic": "Zet FPS naar unlimited", - "widget.settings.interface.fps.warning": "Het zetten van FPS naar unlimited kan prestatie problemen veroorzaken!", - "widget.settings.interface.secondary": "Verander de window header kleur", - "widget.settings.interface.reset": "Reset header kleur naar default", - "widget.room.chat.hide_pets": "Verberg dieren", - "widget.room.chat.hide_avatars": "Verberg avatars", - "widget.room.chat.hide_balloon": "Verberg Spreekballon", - "widget.room.chat.show_balloon": "Spreekballon", - "widget.room.chat.clear_history": "leeg geschiedenis", - "widget.room.youtube.shared": "YouTube word gedeeld", - "widget.room.youtube.open_video": "Open de video", - "widget.memenu.dance1": "Ballo 1", - "widget.memenu.dance2": "Ballo 2", - "widget.memenu.dance3": "Ballo 3", - "widget.memenu.dance4": "Ballo 4", - "wiredfurni.params.action.sign.0": "Cartello 0", - "wiredfurni.params.action.sign.1": "Cartello 1", - "wiredfurni.params.action.sign.2": "Cartello 2", - "wiredfurni.params.action.sign.3": "Cartello 3", - "wiredfurni.params.action.sign.4": "Cartello 4", - "wiredfurni.params.action.sign.5": "Cartello 5", - "wiredfurni.params.action.sign.6": "Cartello 6", - "wiredfurni.params.action.sign.7": "Cartello 7", - "wiredfurni.params.action.sign.8": "Cartello 8", - "wiredfurni.params.action.sign.9": "Cartello 9", - "wiredfurni.params.action.sign.10": "Cartello 10", - "wiredfurni.params.action.sign.11": "Cartello 11", - "wiredfurni.params.action.sign.12": "Cartello 12", - "wiredfurni.params.action.sign.13": "Cartello 13", - "wiredfurni.params.action.sign.14": "Cartello 14", - "wiredfurni.params.action.sign.15": "Cartello 15", - "wiredfurni.params.action.sign.16": "Cartello 16", - "wiredfurni.params.action.sign.17": "Cartello 17", - "groupforum.list.tab.most_active": "Meest active threads", - "groupforum.list.tab.my_forums": "Mijn group forums", - "groupforum.list.no_forums": "Er zijn geen forums", - "groupforum.view.threads": "Aantal threads", - "groupforum.thread.pin": "Pin hem vast", - "groupforum.thread.unpin": "Unpin bericht", - "groupforum.thread.lock": "Lock de thread", - "groupforum.thread.unlock": "Unlock de thread", - "groupforum.thread.hide": "Verberg thread", - "groupforum.thread.restore": "Maak thread weer zichtbaar", - "groupforum.thread.delete": "Verwijder thread + posts", - "groupforum.message.hide": "Verberg bericht", - "group.forum.enable.caption": "Enable / Disable Group forum", - "group.forum.enable.help": "Als je de group forum disabled dan verwijderen ook alle posts!" -} + "widget.settings.interface.title": "Adjust the interface settings", + "widget.settings.interface.fps.automatic": "Set FPS to unlimited", + "widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!", + "widget.settings.interface.secondary": "Change the window header color", + "widget.settings.interface.reset": "Reset header color to default", + "widget.room.chat.hide_pets": "Hide pets", + "widget.room.chat.hide_avatars": "Hide avatars", + "widget.room.chat.hide_balloon": "Hide speech bubble", + "widget.room.chat.show_balloon": "Speech bubble", + "widget.room.chat.clear_history": "clear history", + "widget.room.youtube.shared": "YouTube is being shared", + "widget.room.youtube.open_video": "Open the video", + "wiredfurni.tooltip.select.tile": "Select tile", + "wiredfurni.tooltip.remove.tile": "Deselect tile", + "wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles", + "wiredfurni.tooltip.remove.clear_tile": "Clear all selections", + "wiredfurni.params.furni_neighborhood.group.user": "Players", + "wiredfurni.params.furni_neighborhood.group.furni": "Furniture", + "wiredfurni.params.selector_option.bot": "No bots", + "wiredfurni.params.selector_option.pet": "No pets", + "catalog.title": "Catalog", + "catalog.favorites": "Favorites", + "catalog.favorites.pages": "Pages", + "catalog.favorites.furni": "Furni", + "catalog.favorites.empty": "No favorites", + "catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.", + "catalog.admin": "Admin", + "catalog.admin.new": "New", + "catalog.admin.root": "Root", + "catalog.admin.new.root.category": "New root category", + "catalog.admin.edit.root": "Edit Root", + "catalog.admin.edit": "Edit:", + "catalog.admin.edit.page": "Edit Page", + "catalog.admin.hidden": "hidden", + "catalog.admin.edit.title": "Edit \"%name%\"", + "catalog.admin.show": "Show", + "catalog.admin.hide": "Hide", + "catalog.admin.delete": "Delete", + "catalog.admin.delete.title": "Delete \"%name%\"", + "catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?", + "catalog.admin.delete.page": "Delete page", + "catalog.admin.delete.page.confirm": "Delete page \"%name%\"?", + "catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?", + "catalog.admin.create": "Create", + "catalog.admin.save": "Save", + "catalog.admin.create.subpage": "Create sub-page", + "catalog.admin.order": "Order", + "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" +} \ No newline at end of file diff --git a/public/ads.txt b/public/ads.txt new file mode 100644 index 0000000..3f282eb --- /dev/null +++ b/public/ads.txt @@ -0,0 +1 @@ +google.com, ## YOUR pub-XXXXXXXXX, DIRECT, XXXXXXX diff --git a/public/adsense.json b/public/adsense.json new file mode 100644 index 0000000..8dcba1b --- /dev/null +++ b/public/adsense.json @@ -0,0 +1,5 @@ +{ + "slot": "### SLOT ID FROM GOOGLE - data-ad-slot ###", + "format": "auto", + "fullWidthResponsive": true +} diff --git a/public/renderer-config.json b/public/renderer-config.json index 42f4e11..229a8ef 100644 --- a/public/renderer-config.json +++ b/public/renderer-config.json @@ -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", "image.library.url": "https://client.slogga.it/c_images/", "hof.furni.url": "https://client.slogga.it/c_images/dcr/hof_furni", @@ -41,6 +42,13 @@ "room.color.skip.transition": true, "room.landscapes.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": [ "bd:1", "li:0" diff --git a/public/ui-config.json b/public/ui-config.example similarity index 99% rename from public/ui-config.json rename to public/ui-config.example index 6a8f1c0..946e5e0 100644 --- a/public/ui-config.json +++ b/public/ui-config.example @@ -27,6 +27,18 @@ "guides.enabled": true, "toolbar.hide.quests": 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": [ { "clubLevel": 0, diff --git a/src/App.tsx b/src/App.tsx index e5744e3..94128b3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { FC, useCallback, useEffect, useState } from 'react'; import { GetUIVersion } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; +import { LoginView } from './components/login/LoginView'; import { MainView } from './components/MainView'; import { ReconnectView } from './components/reconnect/ReconnectView'; import { useMessageEvent, useNitroEvent } from './hooks'; @@ -14,12 +15,24 @@ export const App: FC<{}> = props => const [ isReady, setIsReady ] = useState(false); const [ errorMessage, setErrorMessage ] = useState(''); const [ homeUrl, setHomeUrl ] = useState(''); + const [ showLogin, setShowLogin ] = useState(false); + const [ prepareTrigger, setPrepareTrigger ] = useState(0); const showSessionExpired = useCallback(() => { const baseUrl = window.location.origin + '/'; setHomeUrl(baseUrl); setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.'); 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) @@ -48,6 +61,36 @@ export const App: FC<{}> = props => 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('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(); return; } @@ -120,12 +163,13 @@ export const App: FC<{}> = props => { if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval); }; - }, []); + }, [ prepareTrigger ]); return ( - { !isReady && + { !isReady && !showLogin && 0 } message={ errorMessage } homeUrl={ homeUrl } /> } + { !isReady && showLogin && } { isReady && } diff --git a/src/api/avatar/AvatarEditorThumbnailsHelper.ts b/src/api/avatar/AvatarEditorThumbnailsHelper.ts index ad0db38..c319d2a 100644 --- a/src/api/avatar/AvatarEditorThumbnailsHelper.ts +++ b/src/api/avatar/AvatarEditorThumbnailsHelper.ts @@ -87,6 +87,9 @@ export class AvatarEditorThumbnailsHelper AvatarFigurePartType.PET, 'ptl', 'ptr', + AvatarFigurePartType.MISC, + 'mcl', + 'mcr', ]; private static getThumbnailKey(setType: string, part: IAvatarEditorCategoryPartItem, partColors?: IPartColor[], isDisabled?: boolean): string diff --git a/src/api/avatar/BuildPurchasableClothingFigure.ts b/src/api/avatar/BuildPurchasableClothingFigure.ts new file mode 100644 index 0000000..10aff65 --- /dev/null +++ b/src/api/avatar/BuildPurchasableClothingFigure.ts @@ -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(); +}; diff --git a/src/api/avatar/index.ts b/src/api/avatar/index.ts index 415185e..6049e7a 100644 --- a/src/api/avatar/index.ts +++ b/src/api/avatar/index.ts @@ -2,5 +2,6 @@ export * from './AvatarEditorAction'; export * from './AvatarEditorColorSorter'; export * from './AvatarEditorPartSorter'; export * from './AvatarEditorThumbnailsHelper'; +export * from './BuildPurchasableClothingFigure'; export * from './IAvatarEditorCategory'; export * from './IAvatarEditorCategoryPartItem'; diff --git a/src/api/catalog/FurnitureOffer.ts b/src/api/catalog/FurnitureOffer.ts index 5b68c11..acc1c14 100644 --- a/src/api/catalog/FurnitureOffer.ts +++ b/src/api/catalog/FurnitureOffer.ts @@ -117,4 +117,14 @@ export class FurnitureOffer implements IPurchasableOffer { return true; } + + public get itemIds(): string + { + return String(this._furniData?.id ?? ''); + } + + public get haveOffer(): boolean + { + return false; + } } diff --git a/src/api/catalog/IPurchasableOffer.ts b/src/api/catalog/IPurchasableOffer.ts index b182865..bea7781 100644 --- a/src/api/catalog/IPurchasableOffer.ts +++ b/src/api/catalog/IPurchasableOffer.ts @@ -22,4 +22,6 @@ export interface IPurchasableOffer localizationDescription: string; isLazy: boolean; products: IProduct[]; + itemIds: string; + haveOffer: boolean; } diff --git a/src/api/catalog/Offer.ts b/src/api/catalog/Offer.ts index 0f84b00..96d5734 100644 --- a/src/api/catalog/Offer.ts +++ b/src/api/catalog/Offer.ts @@ -30,8 +30,10 @@ export class Offer implements IPurchasableOffer private _products: IProduct[]; private _badgeCode: string; 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._localizationId = localizationId; @@ -43,6 +45,8 @@ export class Offer implements IPurchasableOffer this._clubLevel = clubLevel; this._products = products; this._bundlePurchaseAllowed = bundlePurchaseAllowed; + this._itemIds = itemIds || ''; + this._haveOffer = haveOffer; this.setPricingModelForProducts(); this.setPricingType(); @@ -182,6 +186,16 @@ export class Offer implements IPurchasableOffer return this._products; } + public get itemIds(): string + { + return this._itemIds; + } + + public get haveOffer(): boolean + { + return this._haveOffer; + } + private setPricingModelForProducts(): void { 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)); } - 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; diff --git a/src/assets/images/wardrobe/misc.png b/src/assets/images/wardrobe/misc.png new file mode 100644 index 0000000..ea88b7f Binary files /dev/null and b/src/assets/images/wardrobe/misc.png differ diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 242f527..7611982 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -25,6 +25,7 @@ import { NavigatorView } from './navigator/NavigatorView'; import { NitrobubbleHiddenView } from './nitrobubblehidden/NitrobubbleHiddenView'; import { NitropediaView } from './nitropedia/NitropediaView'; import { ExternalPluginLoader } from './plugins/ExternalPluginLoader'; +import { GoogleAdsView } from './ads/GoogleAdsView'; import { RightSideView } from './right-side/RightSideView'; import { RoomView } from './room/RoomView'; import { ToolbarView } from './toolbar/ToolbarView'; @@ -112,6 +113,7 @@ export const MainView: FC<{}> = props => + diff --git a/src/components/ads/GoogleAdsView.tsx b/src/components/ads/GoogleAdsView.tsx new file mode 100644 index 0000000..b31574e --- /dev/null +++ b/src/components/ads/GoogleAdsView.tsx @@ -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('show.google.ads', false); + const [ isOpen, setIsOpen ] = useState(false); + const [ publisherId, setPublisherId ] = useState(null); + const [ config, setConfig ] = useState(null); + const [ loadError, setLoadError ] = useState(null); + const insRef = useRef(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 ( + + setIsOpen(false) } /> + +
+ { loadError && +
Ads unavailable: { loadError }
} + { !loadError && (!publisherId || !config) && +
Loading…
} + { !loadError && publisherId && config?.slot && + } + { !loadError && publisherId && config && !config.slot && +
Ad slot not configured in adsense.json
} +
+
+
+ ); +}; diff --git a/src/components/avatar-editor/AvatarEditorView.tsx b/src/components/avatar-editor/AvatarEditorView.tsx index 6d23a3c..a150bec 100644 --- a/src/components/avatar-editor/AvatarEditorView.tsx +++ b/src/components/avatar-editor/AvatarEditorView.tsx @@ -88,11 +88,13 @@ export const AvatarEditorView: FC<{}> = props => const isWardrobe = (modelKey === AvatarEditorFigureCategory.WARDROBE); const isPets = (modelKey === AvatarEditorFigureCategory.PETS); const isNft = (modelKey === AvatarEditorFigureCategory.NFT); + const isMisc = (modelKey === AvatarEditorFigureCategory.MISC); let tabClass = `tab ${ modelKey }`; if(isWardrobe) tabClass = 'tab-wardrobe'; else if(isPets) tabClass = 'tab-pets'; else if(isNft) tabClass = 'tab-nft'; + else if(isMisc) tabClass = 'tab-misc'; return ( setActiveModelKey(modelKey) }> diff --git a/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx b/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx index 7b5d65c..31c88a6 100644 --- a/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx +++ b/src/components/avatar-editor/figure-set/AvatarEditorFigureSetItemView.tsx @@ -84,7 +84,7 @@ export const AvatarEditorFigureSetItemView: FC<{ } { !partItem.isClear && isHC && } diff --git a/src/components/catalog/CatalogAdminContext.tsx b/src/components/catalog/CatalogAdminContext.tsx index b057ae0..4143b87 100644 --- a/src/components/catalog/CatalogAdminContext.tsx +++ b/src/components/catalog/CatalogAdminContext.tsx @@ -207,7 +207,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) setLastError(null); pendingActionRef.current = 'saveOffer'; 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.amount, data.clubOnly === '1' ? 1 : 0, data.extradata, data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType @@ -220,7 +220,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) setLastError(null); pendingActionRef.current = 'createOffer'; SendMessageComposer(new CatalogAdminCreateOfferComposer( - data.pageId, parseInt(data.itemIds) || 0, + data.pageId, data.itemIds || '', data.catalogName, data.costCredits, data.costPoints, data.pointsType, data.amount, data.clubOnly === '1' ? 1 : 0, data.extradata, data.haveOffer === '1', data.offerId_group, data.limitedStack, data.orderNumber, currentType diff --git a/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx b/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx index aa89b82..4fe8c29 100644 --- a/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx +++ b/src/components/catalog/views/admin/CatalogAdminOfferEditView.tsx @@ -16,7 +16,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () => const createOffer = catalogAdmin?.createOffer; const loading = catalogAdmin?.loading ?? false; - const [ itemIds, setItemIds ] = useState('0'); + const [ itemIds, setItemIds ] = useState(''); const [ catalogName, setCatalogName ] = useState(''); const [ costCredits, setCostCredits ] = useState(0); const [ costPoints, setCostPoints ] = useState(0); @@ -37,7 +37,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () => if(editingOffer.offerId === -1) { setIsNew(true); - setItemIds('0'); + setItemIds(''); setCatalogName(''); setCostCredits(0); setCostPoints(0); @@ -53,7 +53,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () => else { setIsNew(false); - setItemIds(String(editingOffer.product?.productClassId || 0)); + setItemIds(editingOffer.itemIds || ''); setCatalogName(editingOffer.localizationName || ''); setCostCredits(editingOffer.priceInCredits); setCostPoints(editingOffer.priceInActivityPoints); @@ -61,7 +61,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () => setAmount(editingOffer.product?.productCount || 1); setClubOnly(editingOffer.clubLevel > 0 ? '1' : '0'); setExtradata(editingOffer.product?.extraParam || ''); - setHaveOffer('1'); + setHaveOffer(editingOffer.haveOffer ? '1' : '0'); setOfferIdGroup(editingOffer.offerId || -1); setLimitedStack(0); setOrderNumber(0); @@ -104,7 +104,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () => 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(
setEditingOffer(null) }> @@ -140,7 +140,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
- setItemIds(e.target.value) } /> + setItemIds(e.target.value) } />
@@ -198,7 +198,7 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
- setExtradata(e.target.value) } /> + setExtradata(e.target.value) } />
setHaveOffer(e.target.checked ? '1' : '0') } /> diff --git a/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx index d64854e..14b4516 100644 --- a/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogViewProductWidgetView.tsx @@ -1,6 +1,6 @@ import { GetAvatarRenderManager, GetSessionDataManager, Vector3d } from '@nitrots/nitro-renderer'; 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 { useCatalog } from '../../../../../hooks'; @@ -24,18 +24,37 @@ export const CatalogViewProductWidgetView: FC<{}> = props => case ProductTypeEnum.FLOOR: { 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 figureSets: number[] = []; for(const part of customParts) { + if(isNaN(part)) continue; + 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); } diff --git a/src/components/friends/views/friends-bar/FriendsBarView.tsx b/src/components/friends/views/friends-bar/FriendsBarView.tsx index 52055d5..62c371e 100644 --- a/src/components/friends/views/friends-bar/FriendsBarView.tsx +++ b/src/components/friends/views/friends-bar/FriendsBarView.tsx @@ -38,7 +38,7 @@ export const FriendBarView: FC<{ onlineFriends: MessengerFriend[]; requestsCount { (requestsCount > 0) &&
- { requestsCount } richieste + { requestsCount } { LocalizeText('friendbar.requests.title') }
} diff --git a/src/components/hc-center/HcCenterView.tsx b/src/components/hc-center/HcCenterView.tsx index 79bc0aa..2519197 100644 --- a/src/components/hc-center/HcCenterView.tsx +++ b/src/components/hc-center/HcCenterView.tsx @@ -1,6 +1,10 @@ import { AddLinkEventTracker, ClubGiftInfoEvent, CreateLinkEvent, GetClubGiftInfo, ILinkEventTracker, RemoveLinkEventTracker, ScrGetKickbackInfoMessageComposer, ScrKickbackData, ScrSendKickbackInfoMessageEvent } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; 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 { useInventoryBadges, useMessageEvent, usePurse, useSessionInfo } from '../../hooks'; @@ -126,73 +130,72 @@ export const HcCenterView: FC<{}> = props => ); return ( - + setIsVisible(false) } /> - -
-
- - - -
-
+ + +
+ + +
-
- - - { LocalizeText('hccenter.status.' + clubStatus) } - + + + + { LocalizeText('hccenter.status.' + clubStatus) } + -
+ { GetConfigurationValue('hc.center')['payday.info'] && - - - -

{ LocalizeText('hccenter.special.title') }

-
{ LocalizeText('hccenter.special.info') }
-
CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['payday.habbopage']) }>{ LocalizeText('hccenter.special.infolink') }
-
-
-
{ LocalizeText('hccenter.special.time.title') }
-
-
-
{ getHcPaydayTime() }
+ + + { LocalizeText('hccenter.special.title') } + { LocalizeText('hccenter.special.info') } +
+ CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['payday.habbopage']) }> + { LocalizeText('hccenter.special.infolink') } +
+
+ + { LocalizeText('hccenter.special.time.title') } + +
+ { getHcPaydayTime() } + { clubStatus === ClubStatus.ACTIVE && -
-
{ LocalizeText('hccenter.special.amount.title') }
-
-
{ getHcPaydayAmount() }
-
- { LocalizeText('hccenter.breakdown.infolink') } -
-
-
} -
+ + { LocalizeText('hccenter.special.amount.title') } + { getHcPaydayAmount() } + CreateLinkEvent('habbopages/' + GetConfigurationValue('hc.center')['payday.habbopage']) }> + { LocalizeText('hccenter.breakdown.infolink') } + + } +
} { GetConfigurationValue('hc.center')['gift.info'] && -
-
-

{ LocalizeText('hccenter.gift.title') }

-
0 ? LocalizeText('hccenter.unclaimedgifts', [ 'unclaimedgifts' ], [ unclaimedGifts.toString() ]) : LocalizeText('hccenter.gift.info') } }>
-
- -
} + + } { GetConfigurationValue('hc.center')['benefits.info'] && -
-
{ LocalizeText('hccenter.general.title') }
-
- -
} + + } ); diff --git a/src/components/index.scss b/src/components/index.scss index 8be9445..a432c27 100644 --- a/src/components/index.scss +++ b/src/components/index.scss @@ -9,7 +9,6 @@ @import './friends/FriendsView'; @import './groups/GroupView'; @import './guide-tool/GuideToolView'; -@import './hc-center/HcCenterView'; @import './help/HelpView'; @import './hotel-view/HotelView'; @import './loading/LoadingView'; diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx new file mode 100644 index 0000000..8116373 --- /dev/null +++ b/src/components/login/LoginView.tsx @@ -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 = ({ onAuthenticated }) => +{ + const [ mode, setMode ] = useState('login'); + const [ username, setUsername ] = useState(''); + const [ password, setPassword ] = useState(''); + const [ error, setError ] = useState(null); + const [ info, setInfo ] = useState(null); + const [ submitting, setSubmitting ] = useState(false); + const [ loginTurnstileToken, setLoginTurnstileToken ] = useState(''); + const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0); + const submitTimeRef = useRef(0); + + const loginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; + + const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue('login_background.colour', '#6eadc8')); + const background = interpolate(loginImages['background'] || GetConfigurationValue('login_background', '')); + const sun = interpolate(loginImages['sun'] || GetConfigurationValue('login_sun', '')); + const drape = interpolate(loginImages['drape'] || GetConfigurationValue('login_drape', '')); + const left = interpolate(loginImages['left'] || GetConfigurationValue('login_left', '')); + const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); + const right = interpolate(loginImages['right'] || GetConfigurationValue('login_right', '')); + + useEffect(() => + { + // eslint-disable-next-line no-console + console.info('[LoginView] resolved background assets', { + 'asset.url': GetConfigurationValue('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('login.endpoint', '/api/auth/login'); + const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); + const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); + const turnstileSiteKey = GetConfigurationValue('login.turnstile.sitekey', ''); + const rawTurnstileEnabled = GetConfigurationValue('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) => + { + 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 = {}; + try { payload = await response.json(); } + catch { /* ignore non-json responses */ } + + return { ok: response.ok, status: response.status, payload }; + }, []); + + const handleLoginSubmit = useCallback(async (event: FormEvent) => + { + 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 ( +
+ { background ?
: null } + { sun ?
: null } + { drape ?
: null } + { left ?
: null } + { rightRepeat ?
: null } + { right ?
: null } + +
+
+
First time here?
+
+ Don't have a Habbo yet? + setMode('register') }>You can create one here +
+
+ +
+
What's your Habbo called?
+
+
+ + setUsername(e.target.value) } + /> +
+
+ + setPassword(e.target.value) } + /> +
+ { turnstileEnabled && mode === 'login' && + setLoginTurnstileToken('') } + onError={ () => setLoginTurnstileToken('') } + resetSignal={ loginTurnstileResetSignal } + /> } + { error &&
{ error }
} + { info &&
{ info }
} +
+ +
+ setMode('forgot') }>Forgotten your password? + +
+
+ + { mode === 'register' && + setMode('login') } + onSubmit={ handleRegisterSubmit } + submitting={ submitting } + error={ error } + info={ info } + turnstileEnabled={ turnstileEnabled } + turnstileSiteKey={ turnstileSiteKey } + /> } + + { mode === 'forgot' && + setMode('login') } + onSubmit={ handleForgotSubmit } + submitting={ submitting } + error={ error } + info={ info } + turnstileEnabled={ turnstileEnabled } + turnstileSiteKey={ turnstileSiteKey } + /> } +
+ ); +}; + +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 = 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(null); + const [ turnstileToken, setTurnstileToken ] = useState(''); + const [ resetSignal, setResetSignal ] = useState(0); + + const resetWidget = useCallback(() => + { + setTurnstileToken(''); + setResetSignal(prev => prev + 1); + }, []); + + const handle = (event: FormEvent) => + { + 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 ( +
+
+
+
+ Create a Habbo + +
+
+
+ + setUsername(e.target.value) } /> +
+
+ + setEmail(e.target.value) } /> +
+
+ + setPassword(e.target.value) } /> +
+
+ + setConfirm(e.target.value) } /> +
+ { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ +
+ +
+
+
+ ); +}; + +interface ForgotDialogProps extends DialogSharedProps +{ + onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; +} + +const ForgotDialog: FC = props => +{ + const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + const [ email, setEmail ] = useState(''); + const [ localError, setLocalError ] = useState(null); + const [ turnstileToken, setTurnstileToken ] = useState(''); + const [ resetSignal, setResetSignal ] = useState(0); + + const resetWidget = useCallback(() => + { + setTurnstileToken(''); + setResetSignal(prev => prev + 1); + }, []); + + const handle = (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + + if(!email.trim()) + { + setLocalError('Please enter your email address.'); + return; + } + + onSubmit({ email: email.trim(), turnstileToken }, resetWidget); + }; + + return ( +
+
+
+
+ Reset password + +
+
+
+ + setEmail(e.target.value) } /> +
+ { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ +
+ +
+
+
+ ); +}; diff --git a/src/components/login/TurnstileWidget.tsx b/src/components/login/TurnstileWidget.tsx new file mode 100644 index 0000000..95d1ffa --- /dev/null +++ b/src/components/login/TurnstileWidget.tsx @@ -0,0 +1,118 @@ +import { FC, useEffect, useRef } from 'react'; + +declare global +{ + interface Window + { + turnstile?: { + render: (container: string | HTMLElement, options: Record) => 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 | null = null; + +const loadTurnstileScript = (): Promise => +{ + if(typeof window === 'undefined') return Promise.resolve(); + if(window.turnstile) return Promise.resolve(); + if(scriptPromise) return scriptPromise; + + scriptPromise = new Promise((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 = props => +{ + const { siteKey, theme = 'light', size = 'normal', onToken, onExpire, onError, resetSignal = 0 } = props; + const containerRef = useRef(null); + const widgetIdRef = useRef(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
; +}; diff --git a/src/components/notification-center/views/alert-layouts/NitroSystemAlertView.tsx b/src/components/notification-center/views/alert-layouts/NitroSystemAlertView.tsx index 58db879..dd47863 100644 --- a/src/components/notification-center/views/alert-layouts/NitroSystemAlertView.tsx +++ b/src/components/notification-center/views/alert-layouts/NitroSystemAlertView.tsx @@ -1,5 +1,5 @@ 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'; interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewProps @@ -9,10 +9,11 @@ interface NotificationDefaultAlertViewProps extends LayoutNotificationAlertViewP export const NitroSystemAlertView: FC = props => { - const { title = 'Nitro', onClose = null, ...rest } = props; + const { title = 'Nitro', onClose = null, classNames = [], ...rest } = props; + const adsEnabled = GetConfigurationValue('show.google.ads', false); return ( - + @@ -23,6 +24,8 @@ export const NitroSystemAlertView: FC = props Renderer: v{ GetRendererVersion() } + { adsEnabled && + }
@@ -35,7 +38,7 @@ export const NitroSystemAlertView: FC = props
- +
); diff --git a/src/components/purse/PurseView.tsx b/src/components/purse/PurseView.tsx index 6bda43e..9c81002 100644 --- a/src/components/purse/PurseView.tsx +++ b/src/components/purse/PurseView.tsx @@ -1,6 +1,6 @@ import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer'; -import { FC, useEffect, useMemo, useState } from 'react'; -import { FaChevronDown, FaLanguage, FaQuestionCircle } from 'react-icons/fa'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { FaChevronDown, FaLanguage, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa'; import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api'; import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common'; import { usePurse } from '../../hooks'; @@ -58,6 +58,33 @@ export const PurseView: FC<{}> = props => { return () => window.clearTimeout(timeout); }, [ isOpen ]); + const handleLogout = useCallback(async (event: React.MouseEvent) => + { + event.stopPropagation(); + + const logoutUrl = GetConfigurationValue('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; return ( @@ -100,6 +127,9 @@ export const PurseView: FC<{}> = props => { +
{ seasonalCurrencies.length > 0 && diff --git a/src/components/room/widgets/furniture/context-menu/PurchasableClothingConfirmView.tsx b/src/components/room/widgets/furniture/context-menu/PurchasableClothingConfirmView.tsx index 85a6f80..60a0676 100644 --- a/src/components/room/widgets/furniture/context-menu/PurchasableClothingConfirmView.tsx +++ b/src/components/room/widgets/furniture/context-menu/PurchasableClothingConfirmView.tsx @@ -1,6 +1,6 @@ import { AvatarFigurePartType, GetAvatarRenderManager, GetSessionDataManager, RedeemItemClothingComposer, RoomObjectCategory, UserFigureComposer } from '@nitrots/nitro-renderer'; 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 { useRoom } from '../../../../../hooks'; @@ -41,22 +41,18 @@ export const PurchasableClothingConfirmView: FC parseInt(part)) + .filter(id => !isNaN(id)); + + for(const setId of setIds) { - case FurniCategory.FIGURE_PURCHASABLE_SET: - 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(GetAvatarRenderManager().isValidFigureSetForGender(setId, gender)) validSets.push(setId); } + + if(validSets.length) mode = MODE_PURCHASABLE_CLOTHING; } } @@ -68,7 +64,7 @@ export const PurchasableClothingConfirmView: FC = props => { const [areBubblesMuted, setAreBubblesMuted] = useState(false); @@ -16,7 +15,6 @@ export const RoomToolsWidgetView: FC<{}> = props => { const [roomTags, setRoomTags] = useState(null); const [isOpen, setIsOpen] = useState(false); const [isOpenHistory, setIsOpenHistory] = useState(false); - const [isCollapsed, setIsCollapsed] = useState(false); const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]); const [plugins, setPlugins] = useState([]); const { navigatorData = null } = useNavigator(); @@ -81,11 +79,10 @@ export const RoomToolsWidgetView: FC<{}> = props => { const onChangeRoomHistory = (roomId: number, roomName: string) => { 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); - newStorage = [ ...newStorage, { roomId, roomName } ]; - - if (newStorage.length > 10) newStorage = newStorage.slice(-10); + if (newStorage.length >= 10) newStorage.shift(); + newStorage = [...newStorage, { roomId, roomName }]; setRoomHistory(newStorage); SetLocalStorage('nitro.room.history', newStorage); @@ -102,55 +99,48 @@ export const RoomToolsWidgetView: FC<{}> = props => { }); useEffect(() => { - if(roomName || roomOwner || (roomTags && roomTags.length)) setIsOpen(true); + setIsOpen(true); + const timeout = setTimeout(() => setIsOpen(false), 5000); + return () => clearTimeout(timeout); }, [roomName, roomOwner, roomTags]); - useEffect(() => { - if(!isCollapsed && (roomName || roomOwner || (roomTags && roomTags.length))) setIsOpen(true); - }, [ isCollapsed, roomName, roomOwner, roomTags ]); - useEffect(() => { setRoomHistory(JSON.parse(window.localStorage.getItem('nitro.room.history') || '[]')); }, []); - return ( -
-
- - - { !isCollapsed && - -
handleToolClick('settings')} /> -
handleToolClick('zoom')} /> -
handleToolClick('chat_history')} /> -
handleToolClick('hiddenbubbles')} /> + useEffect(() => { + const handleTabClose = () => { + window.localStorage.removeItem('nitro.room.history'); + }; + window.addEventListener('beforeunload', handleTabClose); + return () => window.removeEventListener('beforeunload', handleTabClose); + }, []); - {navigatorData.canRate && ( -
handleToolClick('like_room')} /> - )} -
handleToolClick('toggle_room_link')} /> -
handleToolClick('room_history')} /> - {plugins.map(plugin => ( -
plugin.onOpen()} - /> - ))} - } - + return ( +
+
+
handleToolClick('settings')} /> +
handleToolClick('zoom')} /> +
handleToolClick('chat_history')} /> +
handleToolClick('hiddenbubbles')} /> + + {navigatorData.canRate && ( +
handleToolClick('like_room')} /> + )} +
handleToolClick('toggle_room_link')} /> +
handleToolClick('room_history')} /> + {plugins.map(plugin => ( +
plugin.onOpen()} + /> + ))}
-
+
- {(!isCollapsed && (isOpen || !!roomName || !!roomOwner || !!(roomTags && roomTags.length))) && ( + {isOpen && (
@@ -171,7 +161,7 @@ export const RoomToolsWidgetView: FC<{}> = props => {
)} - {(!isCollapsed && isOpenHistory) && ( + {isOpenHistory && (
{roomHistory.map(history => ( diff --git a/src/components/toolbar/ToolbarItemView.tsx b/src/components/toolbar/ToolbarItemView.tsx index 8789403..3a0822a 100644 --- a/src/components/toolbar/ToolbarItemView.tsx +++ b/src/components/toolbar/ToolbarItemView.tsx @@ -11,7 +11,7 @@ export const ToolbarItemView = forwardRef>; }>> = props => { - const { useGuideTool = false, unseenAchievementCount = 0, children = null } = props; - const elementRef = useRef(null); + const { useGuideTool = false, unseenAchievementCount = 0, setMeExpanded = null, children = null, ...rest } = props; + const elementRef = useRef(); useEffect(() => { @@ -23,22 +22,29 @@ export const ToolbarMeView: FC + { + const onClick = (event: MouseEvent) => setMeExpanded(false); + + document.addEventListener('click', onClick); + + return () => document.removeEventListener(MouseEventType.MOUSE_CLICK, onClick); + }, [ setMeExpanded ]); + return ( -
-
- { (GetConfigurationValue('guides.enabled') && useGuideTool) && - DispatchUiEvent(new GuideToolEvent(GuideToolEvent.TOGGLE_GUIDE_TOOL)) } title={ LocalizeText('guide.help.button.label') } /> } - CreateLinkEvent('achievements/toggle') } title={ LocalizeText('toolbar.icon.label.achievements') }> - { (unseenAchievementCount > 0) && - } - - GetUserProfile(GetSessionDataManager().userId) } title={ LocalizeText('toolbar.icon.label.memenu') } /> - CreateLinkEvent('navigator/search/myworld_view') } title={ LocalizeText('navigator.myworlds') } /> - CreateLinkEvent('avatar-editor/toggle') } title={ LocalizeText('widget.memenu.settings.avatar') } /> - CreateLinkEvent('user-settings/toggle') } title={ LocalizeText('widget.memenu.settings.title') } /> - CreateLinkEvent('groupforum/toggle') } title={ LocalizeText('toolbar.icon.label.forums') } /> - { children } + + { (GetConfigurationValue('guides.enabled') && useGuideTool) && +
DispatchUiEvent(new GuideToolEvent(GuideToolEvent.TOGGLE_GUIDE_TOOL)) } /> } +
CreateLinkEvent('achievements/toggle') }> + { (unseenAchievementCount > 0) && + }
-
+
GetUserProfile(GetSessionDataManager().userId) } /> +
CreateLinkEvent('navigator/search/myworld_view') } /> +
CreateLinkEvent('avatar-editor/toggle') } /> +
CreateLinkEvent('user-settings/toggle') } /> +
CreateLinkEvent('groupforum/toggle') } title={ LocalizeText('toolbar.icon.label.forums') } /> + { children } + ); }; diff --git a/src/css/index.css b/src/css/index.css index 46a61a3..2d45b84 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -1852,23 +1852,23 @@ body { } .avatar-parts { - border: none !important; + border: 1px solid #c5c3c0 !important; position: relative; aspect-ratio: 1; width: 100%; max-width: 42px; - border-radius: 2rem !important; + border-radius: 0.3rem !important; overflow: hidden !important; - background-color: transparent; + background-color: #e9e8e4; &:hover { - box-shadow: 0 0 0 3px #dbdad5 !important; + box-shadow: inset 0 0 0 3px #dbdad5 !important; background-color: #cecdc8 !important; } &:active, &.part-selected { - box-shadow: 0 0 0 3px #c5c3c0 !important; + box-shadow: inset 0 0 0 3px #c5c3c0 !important; background-color: #b1b1b1 !important; } } @@ -1949,6 +1949,15 @@ body { background-position: center; 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 { diff --git a/src/css/login/LoginView.css b/src/css/login/LoginView.css new file mode 100644 index 0000000..c2f15d8 --- /dev/null +++ b/src/css/login/LoginView.css @@ -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); +} + diff --git a/src/css/notification/NotificationCenterView.css b/src/css/notification/NotificationCenterView.css index 45e3e4b..ec2c5e5 100644 --- a/src/css/notification/NotificationCenterView.css +++ b/src/css/notification/NotificationCenterView.css @@ -19,7 +19,7 @@ min-width: auto; } } - + &.nitro-alert-credits { width: 370px; .notification-text { @@ -34,6 +34,19 @@ 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 { diff --git a/src/css/toolbar/ToolBar.css b/src/css/toolbar/ToolBar.css new file mode 100644 index 0000000..0e9f766 --- /dev/null +++ b/src/css/toolbar/ToolBar.css @@ -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; + } +} diff --git a/src/hooks/avatar-editor/useAvatarEditor.ts b/src/hooks/avatar-editor/useAvatarEditor.ts index bb1cedf..06285c7 100644 --- a/src/hooks/avatar-editor/useAvatarEditor.ts +++ b/src/hooks/avatar-editor/useAvatarEditor.ts @@ -71,6 +71,10 @@ const useAvatarEditorState = () => setMaxPaletteCount(partItem.maxPaletteCount || 1); 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 ]); 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.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.MISC] = [ AvatarFigurePartType.MISC ].map(setType => buildCategory(setType)).filter(Boolean); newAvatarModels[AvatarEditorFigureCategory.NFT] = [ AvatarFigurePartType.HEAD, AvatarFigurePartType.HAIR, diff --git a/src/hooks/catalog/useCatalog.ts b/src/hooks/catalog/useCatalog.ts index 5b75c6b..09820ad 100644 --- a/src/hooks/catalog/useCatalog.ts +++ b/src/hooks/catalog/useCatalog.ts @@ -609,7 +609,7 @@ const useCatalogState = () => 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); @@ -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)); } - 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); const matchingNodes = getNodesByOfferId(offer.offerId, true) || getNodesByOfferId(offer.offerId); diff --git a/src/index.tsx b/src/index.tsx index 52d6e2a..26c24fb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -16,6 +16,8 @@ import './css/friends/FriendsView.css'; import './css/hotelview/HotelView.css'; +import './css/login/LoginView.css'; + import './css/icons/icons.css'; @@ -34,6 +36,8 @@ import './css/room/RoomWidgets.css'; import './css/slider.css'; +import './css/toolbar/ToolBar.css'; + import './css/widgets/FurnitureWidgets.css'; createRoot(document.getElementById('root')).render(); diff --git a/vite.config.mjs b/vite.config.mjs index f541b13..8fcb161 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -18,7 +18,7 @@ export default defineConfig({ }, proxy: { '/api': { - target: 'http://localhost:3000', + target: process.env.AUTH_PROXY_TARGET || 'http://localhost:2096', changeOrigin: true, } }