HotelView.tsx 53.6 Ko
Télécharger le fichier
import type { IHotelViewLandingScene, IHotelViewLandingSlot } from '@octane/renderer';
import {
CreateLinkEvent,
GetSessionDataManager,
HotelViewLandingEvent,
HotelViewLandingRequestComposer,
HotelViewLandingResetVotesComposer,
HotelViewLandingSaveComposer,
HotelViewLandingSaveSceneComposer,
HotelViewLandingVoteComposer
} from '@octane/renderer';
import { CSSProperties, FC, FormEvent, PointerEvent, useEffect, useRef, useState } from 'react';
import { GetConfigurationValue, SendMessageComposer } from '../../api';
import { LayoutAvatarImageView } from '../../common';
import { useMessageEvent } from '../../hooks';
import { Checkbox } from '../../common/Checkbox';
import { Select } from '../../common/Select';
import { NumberInput } from '../../common/NumberInput';
import { BaseButton } from '../../common/BaseButton';
import { Hint } from '../../common/Tooltip';
import { HotelViewLiveSlotView } from './widgets/HotelViewLiveSlotView';
interface HotelViewLandingData {
canEdit: boolean;
scene: IHotelViewLandingScene;
slots: IHotelViewLandingSlot[];
}
interface HotelViewSlotConfig {
positionX?: number;
positionY?: number;
secondaryButtonText?: string;
secondaryLink?: string;
endsAt?: string;
catalogPage?: string;
featuredSlot?: number;
useServerLTD?: boolean;
useServerBonusRare?: boolean;
useServerGoal?: boolean;
useServerQuest?: boolean;
badgeCode?: string;
goalCode?: string;
userVote?: number;
voteOptions?: Array<{ id: number; label: string; badgeCode?: string; furniId?: number; currencyType?: number; currencyAmount?: number }>;
voteCounts?: Record<string, number>;
}
interface HotelViewSlotDrag {
slotId: number;
pointerId: number;
offsetX: number;
offsetY: number;
width: number;
height: number;
}
// Left and right edge artwork is permanently pinned to the bottom corners via CSS and
// cannot be moved, so only the drape and Hall of Fame are draggable scene targets.
type HotelViewSceneDragTarget = 'drape' | 'hallOfFame';
interface HotelViewSceneDrag {
target: HotelViewSceneDragTarget;
pointerId: number;
offsetX: number;
offsetY: number;
width: number;
height: number;
scaled: boolean;
}
const HOTEL_VIEW_WIDTH = 1172;
const HOTEL_VIEW_HEIGHT = 822;
/** The gap the scene keeps above the bottom of the window (its old height was the window minus this). */
const HOTEL_VIEW_SCENE_BOTTOM = 55;
const EMPTY_SCENE: IHotelViewLandingScene = { backgroundUrl: '', leftUrl: '', rightUrl: '', drapeUrl: '', leftX: -1, leftY: -1, rightX: -1, rightY: -1, drapeX: -1, drapeY: -1, hallOfFameX: -1, hallOfFameY: -1, hallOfFameEnabled: false, hallOfFameMode: 'latest_registered', hallOfFameCurrencyType: 0, hallOfFameUsers: [] };
const HOTEL_VIEW_ACTIONS = [
{ value: 'catalog/open', label: 'Open catalogue' },
{ value: 'catalog/open/credits', label: 'Open credits catalogue' },
{ value: 'navigator/show', label: 'Open navigator' },
{ value: 'navigator/search/hotel_view', label: 'Find rooms' },
{ value: 'navigator/create', label: 'Create room' },
{ value: 'navigator/goto/home', label: 'Go to home room' },
{ value: 'avatar-editor/show', label: 'Open avatar editor' },
{ value: 'games/toggle', label: 'Open games' },
{ value: 'help/show', label: 'Open help' },
{ value: 'chat-history/show', label: 'Open chat history' },
{ value: 'achievements/show', label: 'Open achievements' }
] as const;
const CUSTOM_ACTION = '__custom__';
const LTD_COUNTDOWN_ICON = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACIAAAAlCAMAAAAQnMtIAAAAPFBMVEX///8AAABYXl+zxs3d6u1IT1IAAACxu75HS0zR3eEqMDY5P0Lr8vSPnqRoeYeosbQ/Q1Oep89DR0hUWFqc27bXAAAAAnRSTlMAAHaTzTgAAAELSURBVHhehdTZbsQwCAXQDJuzztL+/78WrphWaBpyH0wsHwnkRJkmu4iLbZvnDZkjKNhmbDJVJVKEIij6G5AzUwmLoLBEWGPxFMJCUdIopymEid7Wg/JHvnUNojCwRMKMZVX9QiNag4RJSzFPLLRmoydOYNLGPDHzs8yiMGmdOI+lEpyBqJOcuRBEBIRFcmYqpN5hzkyzEwJBPu9QQYgK4dyHiUcQNyX1neLqLoxNYscY4yh574fHbpPty7Lv+1KS+zgQbySC0bgEe1SQ3gyQ1oD05nDCvQFpDcjC91NzZ344iSex/xImG71Oe71EQJATk/fSG5DeoFFvQHoD0hqQ8n1E6vfzsOt/3e0H5XQXxtNRv3EAAAAASUVORK5CYII=';
const SLOT_WIDGET_TYPES: Array<{ value: IHotelViewLandingSlot['type']; label: string; title: string; body: string; buttonText: string; link: string }> = [
{ value: 'bonus', label: 'Bonus Bag', title: 'Bonus Bag', body: '', buttonText: 'Get Credits', link: 'catalog/open/credits' },
{ value: 'promotion', label: 'Promotion', title: 'Promotion', body: '', buttonText: 'Learn more', link: '' },
{ value: 'catalogpromo', label: 'Catalog promotion', title: 'What\'s new?', body: 'Check out the latest furni, offers and activities.', buttonText: 'Open Catalogue', link: 'catalog/open' },
{ value: 'catalogpromosmall', label: 'Catalog promotion (small)', title: 'New in the catalogue', body: 'Discover the latest additions to the catalogue.', buttonText: 'Open Catalogue', link: 'catalog/open' },
{ value: 'expiringcatalogpage', label: 'Expiring catalog page', title: 'Limited offer', body: 'This offer is only available for a limited time.', buttonText: 'Open Catalogue', link: 'catalog/open' },
{ value: 'expiringcatalogpagesmall', label: 'Expiring catalog page (small)', title: 'Limited offer', body: 'This offer is only available for a limited time.', buttonText: 'Open Catalogue', link: 'catalog/open' },
{ value: 'communitygoal', label: 'Community goal / vote', title: 'Community goal', body: 'Work together with the community and vote for your favourite.', buttonText: 'View goal', link: '' },
{ value: 'dailyquest', label: 'Daily quest', title: 'Daily quest', body: 'Complete today\'s quest to earn your reward.', buttonText: 'Open achievements', link: 'achievements/show' },
{ value: 'nextlimitedrarecountdown', label: 'Limited rare countdown', title: 'Next limited rare', body: 'A new limited rare is coming soon.', buttonText: 'Open Catalogue', link: 'catalog/open' },
{ value: 'achievementcompetition_hall_of_fame', label: 'Achievement competition: hall of fame (live)', title: 'Hall of fame', body: 'See the top achievement competitors.', buttonText: 'Open achievements', link: 'achievements/show' },
{ value: 'achievementcompetition_prizes', label: 'Achievement competition: prizes (live)', title: 'Competition prizes', body: 'See the rewards for this achievement competition.', buttonText: 'Open achievements', link: 'achievements/show' },
{ value: 'habbotalentspromo', label: 'Habbo Talents promo', title: 'Habbo Talents', body: 'Learn more about Habbo Talents.', buttonText: 'Open Help', link: 'help/show' },
{ value: 'habbowaypromo', label: 'Habbo Way promo', title: 'Habbo Way', body: 'Learn how to make the hotel a better place.', buttonText: 'Open Help', link: 'help/show' },
{ value: 'safetyquizpromo', label: 'Safety quiz promo', title: 'Safety quiz', body: 'Take the safety quiz and learn how to stay safe.', buttonText: 'Take the quiz', link: 'help/safetybooklet' },
{ value: 'habbomoderationpromo', label: 'Moderation promo', title: 'Hotel moderation', body: 'Learn more about hotel moderation and safety.', buttonText: 'Open Help', link: 'help/show' },
{ value: 'promoarticle', label: 'News articles (live)', title: 'Latest news', body: 'The news of the hotel appears here.', buttonText: '', link: '' },
{ value: 'communitygoalvsmode', label: 'Community goal: versus (live)', title: 'Community challenge', body: 'Which side wins?', buttonText: '', link: '' },
{ value: 'communitygoalvsmodevote', label: 'Community goal: versus with voting (live)', title: 'Community challenge', body: 'Pick your side!', buttonText: '', link: '' },
{ value: 'concurrentusersgoal', label: 'Users online goal (live)', title: 'Habbo Challenge', body: 'Get enough users online at the same time!', buttonText: '', link: '' }
];
const parseSlotConfig = (configJson: string): HotelViewSlotConfig => {
try {
const parsed = JSON.parse(configJson);
return parsed && typeof parsed === 'object' ? parsed as HotelViewSlotConfig : {};
} catch {
return {};
}
};
const serializeSlotConfig = (config: HotelViewSlotConfig) => JSON.stringify(config);
const withoutUserVote = (configJson: string) => {
const { userVote: _userVote, ...config } = parseSlotConfig(configJson);
return serializeSlotConfig(config);
};
const toDateTimeLocalValue = (value: string | undefined) => {
if (!value) return '';
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return value.slice(0, 16);
const offsetDate = new Date(date.getTime() - (date.getTimezoneOffset() * 60000));
return offsetDate.toISOString().slice(0, 16);
};
const toEditableCountdownValue = (value: string | undefined) => {
const localValue = toDateTimeLocalValue(value);
return localValue ? `${localValue.slice(8, 10)}/${localValue.slice(5, 7)}/${localValue.slice(0, 4)} ${localValue.slice(11, 16)}` : '';
};
const parseEditableCountdownValue = (value: string) => {
const match = value.trim().match(/^(\d{2})\/(\d{2})\/(\d{4})\s+(\d{2}):(\d{2})$/);
if (!match) return null;
const [, day, month, year, hour, minute] = match;
const date = new Date(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute));
if (date.getFullYear() !== Number(year) || date.getMonth() !== Number(month) - 1 || date.getDate() !== Number(day) || date.getHours() !== Number(hour) || date.getMinutes() !== Number(minute)) return null;
return `${year}-${month}-${day}T${hour}:${minute}`;
};
const getDefaultConfig = (type: IHotelViewLandingSlot['type']): HotelViewSlotConfig => {
const tomorrow = () => new Date(Date.now() + 86400000).toISOString().slice(0, 16);
switch (type) {
case 'communitygoal': return { voteOptions: [{ id: 1, label: 'Vote option 1' }, { id: 2, label: 'Vote option 2' }] };
case 'nextlimitedrarecountdown': return { useServerLTD: true };
case 'bonus': return { useServerBonusRare: true };
case 'concurrentusersgoal': return { badgeCode: 'ConcurrentUsersReward' };
case 'expiringcatalogpage':
case 'expiringcatalogpagesmall': return { endsAt: tomorrow() };
case 'dailyquest': return { useServerQuest: true, secondaryButtonText: 'Skip quest', secondaryLink: 'achievements/show' };
case 'achievementcompetition_hall_of_fame': return { secondaryButtonText: 'View prizes', secondaryLink: 'achievements/show' };
case 'habbowaypromo': return { secondaryButtonText: 'Open help', secondaryLink: 'help/show' };
default: return {};
}
};
const getSecondsRemaining = (endsAt: string | undefined, now: number) => {
const timestamp = endsAt ? Date.parse(endsAt) : Number.NaN;
return Number.isFinite(timestamp) ? Math.max(0, Math.ceil((timestamp - now) / 1000)) : null;
};
const formatCountdown = (seconds: number) => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
return `${days ? `${days}d ` : ''}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
const getCountdownParts = (seconds: number) => ({
days: Math.floor(seconds / 86400),
hours: Math.floor((seconds % 86400) / 3600),
minutes: Math.floor((seconds % 3600) / 60),
seconds: seconds % 60
});
const resolveImageUrl = (url: string, imageLibraryUrl: string, assetUrl: string) => url
.replaceAll('${image.library.url}', imageLibraryUrl)
.replaceAll('${asset.url}', assetUrl);
export const HotelView: FC = () => {
const containerRef = useRef<HTMLDivElement>(null);
const hotelViewRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const [landingData, setLandingData] = useState<HotelViewLandingData>({ canEdit: false, scene: EMPTY_SCENE, slots: [] });
const [editingSlot, setEditingSlot] = useState<IHotelViewLandingSlot | null>(null);
const [countdownInput, setCountdownInput] = useState('');
const [editingScene, setEditingScene] = useState<IHotelViewLandingScene | null>(null);
const [slotDrag, setSlotDrag] = useState<HotelViewSlotDrag | null>(null);
const [dragPosition, setDragPosition] = useState<{ slotId: number; x: number; y: number } | null>(null);
const dragPositionRef = useRef<{ slotId: number; x: number; y: number } | null>(null);
const [sceneDrag, setSceneDrag] = useState<HotelViewSceneDrag | null>(null);
const [sceneDragPosition, setSceneDragPosition] = useState<{ target: HotelViewSceneDragTarget; x: number; y: number } | null>(null);
const sceneDragPositionRef = useRef<{ target: HotelViewSceneDragTarget; x: number; y: number } | null>(null);
const [now, setNow] = useState(Date.now());
const imageLibraryUrl = GetConfigurationValue<string>('image.library.url', '');
const assetUrl = GetConfigurationValue<string>('asset.url', '');
useMessageEvent<HotelViewLandingEvent>(HotelViewLandingEvent, (event) => {
const parser = event.getParser();
setLandingData({ canEdit: parser.canEdit, scene: parser.scene, slots: parser.slots });
});
useEffect(() => {
SendMessageComposer(new HotelViewLandingRequestComposer());
}, []);
useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(interval);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const updateScale = () => {
const { width, height } = container.getBoundingClientRect();
if (!width || !height) return;
setScale(Math.min(width / HOTEL_VIEW_WIDTH, height / HOTEL_VIEW_HEIGHT));
};
const observer = new ResizeObserver(updateScale);
observer.observe(container);
updateScale();
return () => observer.disconnect();
}, []);
const scene = landingData.scene ?? EMPTY_SCENE;
const backgroundImage = scene.backgroundUrl ? `url("${resolveImageUrl(scene.backgroundUrl, imageLibraryUrl, assetUrl)}")` : 'none';
const stageStyle = {
'--hotel-view-scale': scale,
'--hotel-view-scaled-height': `${HOTEL_VIEW_HEIGHT * scale}px`,
'--hotel-view-scaled-width': `${HOTEL_VIEW_WIDTH * scale}px`
} as CSSProperties;
const containerStyle = {
'--hotel-view-background': backgroundImage,
'--hotel-view-scene-bottom': `${HOTEL_VIEW_SCENE_BOTTOM}px`,
overflow: 'hidden'
} as CSSProperties;
const handleSlotClick = (link: string) => {
if (!link) return;
if (link.startsWith('http://') || link.startsWith('https://')) {
window.open(link, '_blank', 'noopener,noreferrer');
return;
}
CreateLinkEvent(link);
};
const getCatalogLink = (slot: IHotelViewLandingSlot) => {
// AIR SafetyQuizPromoWidget: the button opens the safety booklet, which ends with the quiz.
if (slot.type === 'safetyquizpromo') return 'help/safetybooklet';
const page = parseSlotConfig(slot.configJson).catalogPage?.trim();
return page ? `catalog/open/${page}` : slot.link;
};
const getSelectedAction = (link: string) => HOTEL_VIEW_ACTIONS.some((action) => action.value === link) ? link : CUSTOM_ACTION;
const selectSlotWidgetType = (type: IHotelViewLandingSlot['type']) => {
const preset = SLOT_WIDGET_TYPES.find((widget) => widget.value === type);
if (!preset || !editingSlot) return;
setEditingSlot({ ...editingSlot, type, title: preset.title, body: preset.body, buttonText: preset.buttonText, link: preset.link, progress: type === 'bonus' ? editingSlot.progress : 0, progressLabel: type === 'bonus' ? editingSlot.progressLabel : '', configJson: serializeSlotConfig(getDefaultConfig(type)) });
};
const updateEditingConfig = (callback: (config: HotelViewSlotConfig) => HotelViewSlotConfig) => {
if (!editingSlot) return;
setEditingSlot({ ...editingSlot, configJson: serializeSlotConfig(callback(parseSlotConfig(editingSlot.configJson))) });
};
const resetCommunityGoalVotes = (slotId: number) => {
const clearVote = (slot: IHotelViewLandingSlot) => slot.id === slotId
? { ...slot, configJson: withoutUserVote(slot.configJson) }
: slot;
setLandingData((current) => ({ ...current, slots: current.slots.map(clearVote) }));
setEditingSlot((current) => current?.id === slotId ? clearVote(current) : current);
SendMessageComposer(new HotelViewLandingResetVotesComposer(slotId));
SendMessageComposer(new HotelViewLandingRequestComposer());
};
const saveSlot = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editingSlot) return;
const isCountdown = editingSlot.type === 'nextlimitedrarecountdown' || editingSlot.type === 'expiringcatalogpage' || editingSlot.type === 'expiringcatalogpagesmall';
const endsAt = isCountdown && countdownInput ? parseEditableCountdownValue(countdownInput) : null;
if (isCountdown && countdownInput && !endsAt) return;
const slotToSave = endsAt
? {
...editingSlot,
configJson: serializeSlotConfig({
...parseSlotConfig(editingSlot.configJson),
endsAt,
...(editingSlot.type === 'nextlimitedrarecountdown' ? { useServerLTD: false } : {})
})
}
: editingSlot;
SendMessageComposer(new HotelViewLandingSaveComposer(slotToSave));
setEditingSlot(null);
setCountdownInput('');
};
const saveScene = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editingScene) return;
SendMessageComposer(new HotelViewLandingSaveSceneComposer(editingScene));
setEditingScene(null);
};
const getSlotPositionStyle = (slot: IHotelViewLandingSlot) => {
const config = parseSlotConfig(slot.configJson);
const position = dragPosition?.slotId === slot.id
? dragPosition
: (Number.isFinite(config.positionX) && Number.isFinite(config.positionY) ? { x: config.positionX as number, y: config.positionY as number } : null);
return position ? { left: `${position.x}px`, top: `${position.y}px` } : undefined;
};
const startSlotDrag = (event: PointerEvent<HTMLButtonElement>, slot: IHotelViewLandingSlot) => {
if (!landingData.canEdit || !hotelViewRef.current) return;
const slotElement = event.currentTarget.closest<HTMLElement>('.hotelview-slot');
const hotelRect = hotelViewRef.current.getBoundingClientRect();
const slotRect = slotElement?.getBoundingClientRect();
if (!slotRect || !hotelRect) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
dragPositionRef.current = null;
setSlotDrag({
slotId: slot.id,
pointerId: event.pointerId,
offsetX: (event.clientX - slotRect.left) / scale,
offsetY: (event.clientY - slotRect.top) / scale,
width: slotRect.width / scale,
height: slotRect.height / scale
});
};
const moveSlotDrag = (event: PointerEvent<HTMLButtonElement>) => {
if (!slotDrag || slotDrag.pointerId !== event.pointerId || !hotelViewRef.current) return;
const hotelRect = hotelViewRef.current.getBoundingClientRect();
const x = Math.round(Math.max(0, Math.min(HOTEL_VIEW_WIDTH - slotDrag.width, ((event.clientX - hotelRect.left) / scale) - slotDrag.offsetX)));
const y = Math.round(Math.max(0, Math.min(HOTEL_VIEW_HEIGHT - slotDrag.height, ((event.clientY - hotelRect.top) / scale) - slotDrag.offsetY)));
const position = { slotId: slotDrag.slotId, x, y };
dragPositionRef.current = position;
setDragPosition(position);
};
const finishSlotDrag = (event: PointerEvent<HTMLButtonElement>) => {
if (!slotDrag || slotDrag.pointerId !== event.pointerId) return;
const position = dragPositionRef.current?.slotId === slotDrag.slotId ? dragPositionRef.current : null;
const slot = landingData.slots.find((value) => value.id === slotDrag.slotId);
if (position && slot) {
const updatedSlot = {
...slot,
configJson: serializeSlotConfig({ ...parseSlotConfig(slot.configJson), positionX: position.x, positionY: position.y })
};
setLandingData((current) => ({ ...current, slots: current.slots.map((value) => value.id === updatedSlot.id ? updatedSlot : value) }));
SendMessageComposer(new HotelViewLandingSaveComposer(updatedSlot));
}
setSlotDrag(null);
dragPositionRef.current = null;
setDragPosition(null);
};
const getSceneCoordinates = (target: HotelViewSceneDragTarget) => {
const key = target === 'hallOfFame' ? 'hallOfFame' : target;
const x = scene[`${key}X` as keyof IHotelViewLandingScene] as number;
const y = scene[`${key}Y` as keyof IHotelViewLandingScene] as number;
return sceneDragPosition?.target === target ? sceneDragPosition : { x, y };
};
const getScenePositionStyle = (target: HotelViewSceneDragTarget) => {
const position = getSceneCoordinates(target);
if (!Number.isFinite(position.x) || !Number.isFinite(position.y) || position.x < 0 || position.y < 0) return undefined;
// Hall of Fame lives inside the fixed 1172x822 stage (uniformly scaled), so its
// coordinates are plain design-space pixels.
if (target === 'hallOfFame') {
return { left: `${position.x}px`, top: `${position.y}px`, right: 'auto', bottom: 'auto', transform: 'none' } as CSSProperties;
}
// The edge artwork is a fixed-size image anchored to the viewport-sized container.
// Store it as a fraction of the FREE space (permille, 0-1000): 0 hugs the left/top,
// 1000 hugs the right/bottom, 500 centres it. `left: P%` positions relative to the
// container while `translate(-P%)` pulls back by the same fraction of the image's
// own size, so the two compose into P * (container - image). That keeps a corner
// placement pinned to the same corner at any screen size, instead of drifting as a
// plain top-left percentage would.
const percentX = position.x / 10;
const percentY = position.y / 10;
return { left: `${percentX}%`, top: `${percentY}%`, right: 'auto', bottom: 'auto', transform: `translate(-${percentX}%, -${percentY}%)` } as CSSProperties;
};
const startSceneDrag = (event: PointerEvent<HTMLButtonElement>, target: HotelViewSceneDragTarget) => {
const isScaled = target === 'hallOfFame';
const dragRoot = isScaled ? hotelViewRef.current : containerRef.current;
const element = event.currentTarget.closest<HTMLElement>(target === 'hallOfFame' ? '.hotelview-hall-of-fame' : '.hotelview-edge');
const rootRect = dragRoot?.getBoundingClientRect();
const elementRect = element?.getBoundingClientRect();
if (!landingData.canEdit || !rootRect || !elementRect) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
sceneDragPositionRef.current = null;
setSceneDrag({
target,
pointerId: event.pointerId,
offsetX: (event.clientX - elementRect.left) / (isScaled ? scale : 1),
offsetY: (event.clientY - elementRect.top) / (isScaled ? scale : 1),
width: elementRect.width / (isScaled ? scale : 1),
height: elementRect.height / (isScaled ? scale : 1),
scaled: isScaled
});
};
const moveSceneDrag = (event: PointerEvent<HTMLButtonElement>) => {
if (!sceneDrag || sceneDrag.pointerId !== event.pointerId) return;
const dragRoot = sceneDrag.scaled ? hotelViewRef.current : containerRef.current;
const rootRect = dragRoot?.getBoundingClientRect();
if (!rootRect) return;
const dragScale = sceneDrag.scaled ? scale : 1;
const rootWidth = sceneDrag.scaled ? HOTEL_VIEW_WIDTH : rootRect.width;
const rootHeight = sceneDrag.scaled ? HOTEL_VIEW_HEIGHT : rootRect.height;
const freeWidth = rootWidth - sceneDrag.width;
const freeHeight = rootHeight - sceneDrag.height;
const pixelX = Math.max(0, Math.min(freeWidth, ((event.clientX - rootRect.left) / dragScale) - sceneDrag.offsetX));
const pixelY = Math.max(0, Math.min(freeHeight, ((event.clientY - rootRect.top) / dragScale) - sceneDrag.offsetY));
// Hall of Fame is in the fixed design space (scale-invariant) so it keeps pixel
// coordinates. The container-anchored edges are stored as a fraction of the free
// space in permille (0-1000) — pixelX / (container - image) — so a corner or centre
// placement lands in the same relative spot when reopened at a different screen size
// (see getScenePositionStyle for how it is applied).
const x = sceneDrag.scaled ? Math.round(pixelX) : Math.round(freeWidth > 0 ? (pixelX / freeWidth) * 1000 : 0);
const y = sceneDrag.scaled ? Math.round(pixelY) : Math.round(freeHeight > 0 ? (pixelY / freeHeight) * 1000 : 0);
const position = { target: sceneDrag.target, x, y };
sceneDragPositionRef.current = position;
setSceneDragPosition(position);
};
const finishSceneDrag = (event: PointerEvent<HTMLButtonElement>) => {
if (!sceneDrag || sceneDrag.pointerId !== event.pointerId) return;
const position = sceneDragPositionRef.current?.target === sceneDrag.target ? sceneDragPositionRef.current : null;
if (position) {
const key = position.target === 'hallOfFame' ? 'hallOfFame' : position.target;
const updatedScene = {
...scene,
[`${key}X`]: position.x,
[`${key}Y`]: position.y
} as IHotelViewLandingScene;
setLandingData((current) => ({ ...current, scene: updatedScene }));
SendMessageComposer(new HotelViewLandingSaveSceneComposer(updatedScene));
}
setSceneDrag(null);
sceneDragPositionRef.current = null;
setSceneDragPosition(null);
};
return (
// AIR fills the landing view behind the see-through toolbar; the scene keeps its old box, 55 px up.
<div className="octane-hotel-view block fixed w-full h-full" style={containerStyle}>
<div ref={containerRef} className="hotelview-frame">
{scene.leftUrl
? <div className="hotelview-edge hotelview-edge-left"><img src={resolveImageUrl(scene.leftUrl, imageLibraryUrl, assetUrl)} alt="" /><LayoutAvatarImageView classNames={['hotelview-avatar']} figure={GetSessionDataManager().figure} gender={GetSessionDataManager().gender} direction={2} /></div>
: <LayoutAvatarImageView classNames={['hotelview-avatar hotelview-avatar-fallback']} figure={GetSessionDataManager().figure} gender={GetSessionDataManager().gender} direction={2} />}
{scene.rightUrl && <div className="hotelview-edge hotelview-edge-right"><img src={resolveImageUrl(scene.rightUrl, imageLibraryUrl, assetUrl)} alt="" /></div>}
{scene.drapeUrl && <div className="hotelview-edge hotelview-edge-drape" style={getScenePositionStyle('drape')}><img src={resolveImageUrl(scene.drapeUrl, imageLibraryUrl, assetUrl)} alt="" />{landingData.canEdit && <Hint content="Drag drape">
<BaseButton aria-label="Drag drape" type="button" className="hotelview-scene-drag-handle" onPointerDown={(event) => startSceneDrag(event, 'drape')} onPointerMove={moveSceneDrag} onPointerUp={finishSceneDrag} onPointerCancel={finishSceneDrag}>↕</BaseButton>
</Hint>}</div>}
<div className="hotelview-stage" style={stageStyle}>
<div ref={hotelViewRef} className="hotelview">
<div className="hotelview-background" />
{landingData.canEdit && <BaseButton type="button" className="hotelview-scene-edit hotelview-scene-edit-left" onClick={() => setEditingScene(scene)} aria-label="Edit left image">✎</BaseButton>}
{landingData.canEdit && <BaseButton type="button" className="hotelview-scene-edit hotelview-scene-edit-right" onClick={() => setEditingScene(scene)} aria-label="Edit right image">✎</BaseButton>}
{landingData.canEdit && <BaseButton type="button" className="hotelview-scene-edit hotelview-scene-edit-drape" onClick={() => setEditingScene(scene)} aria-label="Edit drape image">✎</BaseButton>}
{landingData.canEdit && <Hint content="Edit HotelView background"><BaseButton type="button" className="hotelview-scene-edit hotelview-scene-edit-background" onClick={() => setEditingScene(scene)} aria-label="Edit HotelView background">BG</BaseButton></Hint>}
<main className="hotelview-slots">
{landingData.slots.filter((slot) => slot.enabled || landingData.canEdit).map((slot) => (
<section key={slot.id} className={`hotelview-slot hotelview-slot-${slot.id} hotelview-slot-${slot.type}${slot.enabled ? '' : ' hotelview-slot-disabled'}`} style={getSlotPositionStyle(slot)}>
{slot.enabled && <>
{slot.imageUrl && <img className="hotelview-slot-image" src={resolveImageUrl(slot.imageUrl, imageLibraryUrl, assetUrl)} alt="" />}
{!slot.imageUrl && slot.type === 'nextlimitedrarecountdown' && <img className="hotelview-slot-image hotelview-ltd-countdown-icon" src={LTD_COUNTDOWN_ICON} alt="" />}
<div className="hotelview-slot-copy">
{(() => {
const config = parseSlotConfig(slot.configJson);
const isCountdown = slot.type === 'nextlimitedrarecountdown' || slot.type === 'expiringcatalogpage' || slot.type === 'expiringcatalogpagesmall';
const seconds = isCountdown ? getSecondsRemaining(config.endsAt, now) : null;
const isLtdPending = slot.type === 'nextlimitedrarecountdown' && seconds !== null && seconds > 0;
const isExpired = (slot.type === 'expiringcatalogpage' || slot.type === 'expiringcatalogpagesmall') && seconds === 0;
const voteOptions = config.voteOptions || [];
const voteTotal = voteOptions.reduce((total, option) => total + (config.voteCounts?.[String(option.id)] || 0), 0);
const countdown = seconds === null ? null : getCountdownParts(seconds);
const staticContent = <>
{slot.title && <h2>{slot.title}</h2>}
{slot.body && <p>{slot.body}</p>}
{slot.type === 'bonus' && <div className="hotelview-progress"><span style={{ width: `${Math.min(100, Math.max(0, slot.progress))}%` }} /></div>}
{slot.type === 'communitygoal' && <div className="hotelview-progress hotelview-community-progress" aria-label={`${voteTotal} votes`}>{voteOptions.slice(0, 2).map((option, index) => <span key={option.id} className={`hotelview-community-progress-option-${index + 1}`} style={{ width: `${voteTotal ? ((config.voteCounts?.[String(option.id)] || 0) / voteTotal) * 100 : 0}%` }} />)}</div>}
{seconds !== null && (slot.type === 'nextlimitedrarecountdown'
? <strong className="hotelview-countdown hotelview-ltd-countdown">{seconds ? <><span className="hotelview-ltd-countdown-values">{countdown?.days.toString().padStart(2, '0')}:{countdown?.hours.toString().padStart(2, '0')}:{countdown?.minutes.toString().padStart(2, '0')}:{countdown?.seconds.toString().padStart(2, '0')}</span><small>Days Hours Minutes Seconds</small></> : 'Available now'}</strong>
: <strong className="hotelview-countdown">{isExpired ? 'Expired' : (seconds ? formatCountdown(seconds) : 'Available now')}</strong>)}
{slot.progressLabel && <strong>{slot.progressLabel}</strong>}
{slot.type === 'communitygoal'
? <div className="hotelview-vote-options">{voteOptions.map((option) => <BaseButton key={option.id} className={config.userVote === option.id ? 'hotelview-vote-selected' : ''} type="button" disabled={config.userVote !== undefined} onClick={() => SendMessageComposer(new HotelViewLandingVoteComposer(slot.id, option.id))}>{option.label} <span>{config.voteCounts?.[String(option.id)] || 0}</span></BaseButton>)}</div>
: <>{slot.buttonText && <BaseButton type="button" disabled={isLtdPending || isExpired} onClick={() => handleSlotClick(getCatalogLink(slot))}>{isLtdPending ? 'Coming soon' : slot.buttonText}</BaseButton>}{config.secondaryButtonText && <BaseButton type="button" onClick={() => handleSlotClick(config.secondaryLink || '')}>{config.secondaryButtonText}</BaseButton>}</>}
</>;
return <HotelViewLiveSlotView slot={slot} config={config} fallback={staticContent} onLink={handleSlotClick} />;
})()}
</div>
</>}
{!slot.enabled && <span>Disabled slot</span>}
{landingData.canEdit && <Hint content="Drag to move">
<BaseButton type="button" className="hotelview-slot-drag-handle" onPointerDown={(event) => startSlotDrag(event, slot)} onPointerMove={moveSlotDrag} onPointerUp={finishSlotDrag} onPointerCancel={finishSlotDrag} aria-label={`Move slot ${slot.id}`}>↕</BaseButton>
</Hint>}
{landingData.canEdit && <BaseButton type="button" className="hotelview-slot-edit" onClick={() => { setEditingSlot(slot); setCountdownInput(toEditableCountdownValue(parseSlotConfig(slot.configJson).endsAt)); }} aria-label={`Edit slot ${slot.id}`}>✎</BaseButton>}
</section>
))}
</main>
{scene.hallOfFameEnabled && scene.hallOfFameUsers.length > 0 && <div className="hotelview-hall-of-fame" style={getScenePositionStyle('hallOfFame')} aria-label="Hall of Fame">
<header>
<h2>Hall of Fame</h2>
<p>Celebrating the hotel’s standout Habbos</p>
</header>
{scene.hallOfFameUsers.map((user, index) => <div key={user.id} className="hotelview-hall-of-fame-user" title={user.username}>
<LayoutAvatarImageView classNames={['hotelview-hall-of-fame-avatar']} figure={user.figure} gender={user.gender} direction={[2, 4][index % 2]} />
<span>{user.username}</span>
</div>)}
{landingData.canEdit && <Hint content="Drag Hall of Fame">
<BaseButton aria-label="Drag Hall of Fame" type="button" className="hotelview-hall-of-fame-drag-handle" onPointerDown={(event) => startSceneDrag(event, 'hallOfFame')} onPointerMove={moveSceneDrag} onPointerUp={finishSceneDrag} onPointerCancel={finishSceneDrag}>↕</BaseButton>
</Hint>}
</div>}
{editingSlot && (
<form className="hotelview-editor" onSubmit={saveSlot}>
<header>Edit slot {editingSlot.id}</header>
<div className={`hotelview-editor-preview hotelview-editor-slot-preview${editingSlot.enabled ? '' : ' hotelview-slot-disabled'}`}>
<span className="hotelview-editor-preview-label">Preview</span>
{editingSlot.imageUrl && <img src={resolveImageUrl(editingSlot.imageUrl, imageLibraryUrl, assetUrl)} alt="" />}
<div>
{editingSlot.title && <strong>{editingSlot.title}</strong>}
{editingSlot.body && <p>{editingSlot.body}</p>}
{editingSlot.type === 'communitygoal'
? <div className="hotelview-editor-preview-votes">{(parseSlotConfig(editingSlot.configJson).voteOptions || []).slice(0, 2).map((option) => <BaseButton key={option.id} type="button">{option.label || `Vote ${option.id}`}</BaseButton>)}</div>
: editingSlot.buttonText && <BaseButton type="button">{editingSlot.buttonText}</BaseButton>}
</div>
{!editingSlot.enabled && <em>Disabled slot</em>}
</div>
<label className="hotelview-editor-toggle"><Checkbox checked={editingSlot.enabled} onCheckedChange={(nextChecked) => setEditingSlot({ ...editingSlot, enabled: nextChecked })} /><span>Enabled: {editingSlot.enabled ? 'Yes' : 'No'}</span></label>
<label>Widget type<Select value={editingSlot.type} onChange={(event) => selectSlotWidgetType(event.target.value as IHotelViewLandingSlot['type'])}>{SLOT_WIDGET_TYPES.map((widget) => <option key={widget.value} value={widget.value}>{widget.label}</option>)}</Select></label>
<label>Title<input value={editingSlot.title} onChange={(event) => setEditingSlot({ ...editingSlot, title: event.target.value })} /></label>
<label>Body<textarea value={editingSlot.body} onChange={(event) => setEditingSlot({ ...editingSlot, body: event.target.value })} /></label>
<label>Image URL<input value={editingSlot.imageUrl} onChange={(event) => setEditingSlot({ ...editingSlot, imageUrl: event.target.value })} /></label>
<label>Button<input value={editingSlot.buttonText} onChange={(event) => setEditingSlot({ ...editingSlot, buttonText: event.target.value })} /></label>
<label>Button action<Select value={getSelectedAction(editingSlot.link)} onChange={(event) => setEditingSlot({ ...editingSlot, link: event.target.value === CUSTOM_ACTION ? '' : event.target.value })}><option value={CUSTOM_ACTION}>Custom link</option>{HOTEL_VIEW_ACTIONS.map((action) => <option key={action.value} value={action.value}>{action.label}</option>)}</Select></label>
<label>Custom link<input value={editingSlot.link} placeholder="navigator/goto/123" onChange={(event) => setEditingSlot({ ...editingSlot, link: event.target.value })} /></label>
{['catalogpromo', 'catalogpromosmall', 'expiringcatalogpage', 'expiringcatalogpagesmall', 'nextlimitedrarecountdown'].includes(editingSlot.type) && <label>Catalog page name / ID<input value={parseSlotConfig(editingSlot.configJson).catalogPage || ''} placeholder="limited_rares" onChange={(event) => updateEditingConfig((config) => ({ ...config, catalogPage: event.target.value }))} /></label>}
{(editingSlot.type === 'expiringcatalogpage' || editingSlot.type === 'expiringcatalogpagesmall') && <label>Featured catalog slot<NumberInput min="1" value={parseSlotConfig(editingSlot.configJson).featuredSlot || ''} placeholder="Optional" onChange={(event) => updateEditingConfig((config) => ({ ...config, featuredSlot: Number(event.target.value) || undefined }))} /></label>}
{editingSlot.type !== 'communitygoal' && <><label>Progress<NumberInput min="0" max="100" value={editingSlot.progress} onChange={(event) => setEditingSlot({ ...editingSlot, progress: Number(event.target.value) })} /></label><label>Progress label<input value={editingSlot.progressLabel} onChange={(event) => setEditingSlot({ ...editingSlot, progressLabel: event.target.value })} /></label></>}
{editingSlot.type === 'nextlimitedrarecountdown' && <label className="hotelview-editor-toggle"><Checkbox checked={parseSlotConfig(editingSlot.configJson).useServerLTD !== false} onCheckedChange={(nextChecked) => updateEditingConfig((config) => ({ ...config, useServerLTD: nextChecked }))} /><span>Use emulator LTD configuration: {parseSlotConfig(editingSlot.configJson).useServerLTD !== false ? 'Yes' : 'No'}</span></label>}
{editingSlot.type === 'bonus' && <label className="hotelview-editor-toggle"><Checkbox checked={parseSlotConfig(editingSlot.configJson).useServerBonusRare !== false} onCheckedChange={(nextChecked) => updateEditingConfig((config) => ({ ...config, useServerBonusRare: nextChecked }))} /><span>Live bonus rare from the emulator: {parseSlotConfig(editingSlot.configJson).useServerBonusRare !== false ? 'Yes' : 'No'}</span></label>}
{editingSlot.type === 'communitygoal' && <label className="hotelview-editor-toggle"><Checkbox checked={parseSlotConfig(editingSlot.configJson).useServerGoal === true} onCheckedChange={(nextChecked) => updateEditingConfig((config) => ({ ...config, useServerGoal: nextChecked }))} /><span>Show the emulator community goal meter instead of these votes: {parseSlotConfig(editingSlot.configJson).useServerGoal === true ? 'Yes' : 'No'}</span></label>}
{editingSlot.type === 'dailyquest' && <label className="hotelview-editor-toggle"><Checkbox checked={parseSlotConfig(editingSlot.configJson).useServerQuest !== false} onCheckedChange={(nextChecked) => updateEditingConfig((config) => ({ ...config, useServerQuest: nextChecked }))} /><span>Live daily quest from the emulator: {parseSlotConfig(editingSlot.configJson).useServerQuest !== false ? 'Yes' : 'No'}</span></label>}
{editingSlot.type === 'achievementcompetition_hall_of_fame' && <label>Competition goal code<input value={parseSlotConfig(editingSlot.configJson).goalCode || ''} placeholder="Empty = the active community goal" onChange={(event) => updateEditingConfig((config) => ({ ...config, goalCode: event.target.value }))} /></label>}
{editingSlot.type === 'concurrentusersgoal' && <label>Reward badge code<input value={parseSlotConfig(editingSlot.configJson).badgeCode || ''} placeholder="ConcurrentUsersReward" onChange={(event) => updateEditingConfig((config) => ({ ...config, badgeCode: event.target.value }))} /></label>}
{(editingSlot.type === 'nextlimitedrarecountdown' || editingSlot.type === 'expiringcatalogpage' || editingSlot.type === 'expiringcatalogpagesmall') && <label>Countdown ends<input className="hotelview-countdown-editor-input" value={countdownInput} placeholder="DD/MM/YYYY HH:mm" onChange={(event) => {
setCountdownInput(event.target.value);
if (editingSlot.type === 'nextlimitedrarecountdown' && event.target.value.trim()) updateEditingConfig((config) => ({ ...config, useServerLTD: false }));
}} /></label>}
{editingSlot.type === 'communitygoal' && <>
<div className="hotelview-vote-rewards">
<fieldset className="hotelview-vote-reward-panel"><legend>Vote option 1</legend>
<label>Label<input value={parseSlotConfig(editingSlot.configJson).voteOptions?.[0]?.label || ''} onChange={(event) => updateEditingConfig((config) => ({ ...config, voteOptions: [{ ...(config.voteOptions?.[0] || { id: 1 }), label: event.target.value }, config.voteOptions?.[1] || { id: 2, label: 'Vote option 2' }] }))} /></label>
<label>Badge code<input value={parseSlotConfig(editingSlot.configJson).voteOptions?.[0]?.badgeCode || ''} placeholder="badge_code" onChange={(event) => updateEditingConfig((config) => ({ ...config, voteOptions: [{ ...(config.voteOptions?.[0] || { id: 1, label: 'Vote option 1' }), badgeCode: event.target.value }, config.voteOptions?.[1] || { id: 2, label: 'Vote option 2' }] }))} /></label>
<label>Furni item ID<NumberInput min="1" value={parseSlotConfig(editingSlot.configJson).voteOptions?.[0]?.furniId || ''} placeholder="Optional" onChange={(event) => updateEditingConfig((config) => ({ ...config, voteOptions: [{ ...(config.voteOptions?.[0] || { id: 1, label: 'Vote option 1' }), furniId: event.target.value ? Number(event.target.value) : undefined }, config.voteOptions?.[1] || { id: 2, label: 'Vote option 2' }] }))} /></label>
<label>Currency type<NumberInput min="0" value={parseSlotConfig(editingSlot.configJson).voteOptions?.[0]?.currencyType ?? ''} placeholder="0 = pixels" onChange={(event) => updateEditingConfig((config) => ({ ...config, voteOptions: [{ ...(config.voteOptions?.[0] || { id: 1, label: 'Vote option 1' }), currencyType: event.target.value === '' ? undefined : Number(event.target.value) }, config.voteOptions?.[1] || { id: 2, label: 'Vote option 2' }] }))} /></label>
<label>Currency quantity<NumberInput min="1" value={parseSlotConfig(editingSlot.configJson).voteOptions?.[0]?.currencyAmount || ''} placeholder="Optional" onChange={(event) => updateEditingConfig((config) => ({ ...config, voteOptions: [{ ...(config.voteOptions?.[0] || { id: 1, label: 'Vote option 1' }), currencyAmount: event.target.value ? Number(event.target.value) : undefined }, config.voteOptions?.[1] || { id: 2, label: 'Vote option 2' }] }))} /></label>
</fieldset>
<fieldset className="hotelview-vote-reward-panel"><legend>Vote option 2</legend>
<label>Label<input value={parseSlotConfig(editingSlot.configJson).voteOptions?.[1]?.label || ''} onChange={(event) => updateEditingConfig((config) => ({ ...config, voteOptions: [config.voteOptions?.[0] || { id: 1, label: 'Vote option 1' }, { ...(config.voteOptions?.[1] || { id: 2 }), label: event.target.value }] }))} /></label>
<label>Badge code<input value={parseSlotConfig(editingSlot.configJson).voteOptions?.[1]?.badgeCode || ''} placeholder="badge_code" onChange={(event) => updateEditingConfig((config) => ({ ...config, voteOptions: [config.voteOptions?.[0] || { id: 1, label: 'Vote option 1' }, { ...(config.voteOptions?.[1] || { id: 2, label: 'Vote option 2' }), badgeCode: event.target.value }] }))} /></label>
<label>Furni item ID<NumberInput min="1" value={parseSlotConfig(editingSlot.configJson).voteOptions?.[1]?.furniId || ''} placeholder="Optional" onChange={(event) => updateEditingConfig((config) => ({ ...config, voteOptions: [config.voteOptions?.[0] || { id: 1, label: 'Vote option 1' }, { ...(config.voteOptions?.[1] || { id: 2, label: 'Vote option 2' }), furniId: event.target.value ? Number(event.target.value) : undefined }] }))} /></label>
<label>Currency type<NumberInput min="0" value={parseSlotConfig(editingSlot.configJson).voteOptions?.[1]?.currencyType ?? ''} placeholder="0 = pixels" onChange={(event) => updateEditingConfig((config) => ({ ...config, voteOptions: [config.voteOptions?.[0] || { id: 1, label: 'Vote option 1' }, { ...(config.voteOptions?.[1] || { id: 2, label: 'Vote option 2' }), currencyType: event.target.value === '' ? undefined : Number(event.target.value) }] }))} /></label>
<label>Currency quantity<NumberInput min="1" value={parseSlotConfig(editingSlot.configJson).voteOptions?.[1]?.currencyAmount || ''} placeholder="Optional" onChange={(event) => updateEditingConfig((config) => ({ ...config, voteOptions: [config.voteOptions?.[0] || { id: 1, label: 'Vote option 1' }, { ...(config.voteOptions?.[1] || { id: 2, label: 'Vote option 2' }), currencyAmount: event.target.value ? Number(event.target.value) : undefined }] }))} /></label>
</fieldset>
</div>
<BaseButton className="hotelview-reset-votes" type="button" onClick={() => resetCommunityGoalVotes(editingSlot.id)}>Reset all community votes</BaseButton>
</>}
{editingSlot.type !== 'communitygoal' && <>
<label>Secondary button<input value={parseSlotConfig(editingSlot.configJson).secondaryButtonText || ''} onChange={(event) => updateEditingConfig((config) => ({ ...config, secondaryButtonText: event.target.value }))} /></label>
<label>Secondary action<Select value={getSelectedAction(parseSlotConfig(editingSlot.configJson).secondaryLink || '')} onChange={(event) => updateEditingConfig((config) => ({ ...config, secondaryLink: event.target.value === CUSTOM_ACTION ? '' : event.target.value }))}><option value={CUSTOM_ACTION}>Custom link</option>{HOTEL_VIEW_ACTIONS.map((action) => <option key={action.value} value={action.value}>{action.label}</option>)}</Select></label>
<label>Secondary custom link<input value={parseSlotConfig(editingSlot.configJson).secondaryLink || ''} onChange={(event) => updateEditingConfig((config) => ({ ...config, secondaryLink: event.target.value }))} /></label>
</>}
<footer><BaseButton type="button" onClick={() => { setEditingSlot(null); setCountdownInput(''); }}>Cancel</BaseButton><BaseButton type="submit">Save</BaseButton></footer>
</form>
)}
{editingScene && (
<form className="hotelview-editor hotelview-scene-editor" onSubmit={saveScene}>
<header>Edit HotelView images</header>
<div className="hotelview-editor-preview hotelview-editor-scene-preview">
<span className="hotelview-editor-preview-label">Background preview</span>
<div className="hotelview-editor-scene-canvas" style={{ backgroundImage: editingScene.backgroundUrl ? `url(${resolveImageUrl(editingScene.backgroundUrl, imageLibraryUrl, assetUrl)})` : undefined }}>
{editingScene.leftUrl && <img className="hotelview-editor-scene-left" src={resolveImageUrl(editingScene.leftUrl, imageLibraryUrl, assetUrl)} alt="" />}
{editingScene.drapeUrl && <img className="hotelview-editor-scene-drape" src={resolveImageUrl(editingScene.drapeUrl, imageLibraryUrl, assetUrl)} alt="" />}
{editingScene.rightUrl && <img className="hotelview-editor-scene-right" src={resolveImageUrl(editingScene.rightUrl, imageLibraryUrl, assetUrl)} alt="" />}
</div>
</div>
<label>Background URL<input value={editingScene.backgroundUrl} onChange={(event) => setEditingScene({ ...editingScene, backgroundUrl: event.target.value })} /></label>
<label>Drape URL<input value={editingScene.drapeUrl} onChange={(event) => setEditingScene({ ...editingScene, drapeUrl: event.target.value })} /></label>
<label>Left image URL<input value={editingScene.leftUrl} onChange={(event) => setEditingScene({ ...editingScene, leftUrl: event.target.value })} /></label>
<label>Right image URL<input value={editingScene.rightUrl} onChange={(event) => setEditingScene({ ...editingScene, rightUrl: event.target.value })} /></label>
<label className="hotelview-editor-toggle"><Checkbox checked={editingScene.hallOfFameEnabled} onCheckedChange={(nextChecked) => setEditingScene({ ...editingScene, hallOfFameEnabled: nextChecked })} /><span>Hall of Fame: {editingScene.hallOfFameEnabled ? 'Enabled' : 'Disabled'}</span></label>
{editingScene.hallOfFameEnabled && <>
<label>Hall of Fame ranking<Select value={editingScene.hallOfFameMode} onChange={(event) => setEditingScene({ ...editingScene, hallOfFameMode: event.target.value })}><option value="latest_registered">Latest registered users</option><option value="online_time">Most online time</option><option value="achievement_score">Highest achievement score</option><option value="currency">Highest currency balance</option></Select></label>
{editingScene.hallOfFameMode === 'currency' && <label>Currency type<NumberInput min="0" value={editingScene.hallOfFameCurrencyType} onChange={(event) => setEditingScene({ ...editingScene, hallOfFameCurrencyType: Math.max(0, Number(event.target.value) || 0) })} /></label>}
</>}
<footer><BaseButton type="button" onClick={() => setEditingScene(null)}>Cancel</BaseButton><BaseButton type="submit">Save</BaseButton></footer>
</form>
)}
</div>
</div>
</div>
</div>
);
};
