Add coolui test

This commit is contained in:
Remco
2026-01-26 19:06:51 +01:00
parent 71a4c6677e
commit d872183654
1734 changed files with 56797 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
import { AddLinkEventTracker, GetCommunication, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
import { AnimatePresence, motion } from 'framer-motion';
import { FC, useEffect, useState } from 'react';
import { useNitroEvent } from '../hooks';
import { AchievementsView } from './achievements/AchievementsView';
import { AvatarEditorView } from './avatar-editor';
import { CameraWidgetView } from './camera/CameraWidgetView';
import { CampaignView } from './campaign/CampaignView';
import { CatalogView } from './catalog/CatalogView';
import { ChatHistoryView } from './chat-history/ChatHistoryView';
import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView';
import { FriendsView } from './friends/FriendsView';
import { GameCenterView } from './game-center/GameCenterView';
import { GroupsView } from './groups/GroupsView';
import { GuideToolView } from './guide-tool/GuideToolView';
import { HcCenterView } from './hc-center/HcCenterView';
import { HelpView } from './help/HelpView';
import { HotelView } from './hotel-view/HotelView';
import { InventoryView } from './inventory/InventoryView';
import { ModToolsView } from './mod-tools/ModToolsView';
import { NavigatorView } from './navigator/NavigatorView';
import { NitrobubbleHiddenView } from './nitrobubblehidden/NitrobubbleHiddenView';
import { NitropediaView } from './nitropedia/NitropediaView';
import { RightSideView } from './right-side/RightSideView';
import { RoomView } from './room/RoomView';
import { ToolbarView } from './toolbar/ToolbarView';
import { UserProfileView } from './user-profile/UserProfileView';
import { UserSettingsView } from './user-settings/UserSettingsView';
import { WiredView } from './wired/WiredView';
export const MainView: FC<{}> = props =>
{
const [ isReady, setIsReady ] = useState(false);
const [ landingViewVisible, setLandingViewVisible ] = useState(true);
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event => setLandingViewVisible(false));
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView));
useEffect(() =>
{
setIsReady(true);
GetCommunication().connection.ready();
}, []);
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'open':
if(parts.length > 2)
{
switch(parts[2])
{
case 'credits':
//HabboWebTools.openWebPageAndMinimizeClient(this._windowManager.getProperty(ExternalVariables.WEB_SHOP_RELATIVE_URL));
break;
default: {
const name = parts[2];
HabboWebTools.openHabblet(name);
}
}
}
return;
}
},
eventUrlPrefix: 'habblet/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
return (
<>
<AnimatePresence>
{ landingViewVisible &&
<motion.div
initial={ { opacity: 0 }}
animate={ { opacity: 1 }}
exit={ { opacity: 0 }}>
<HotelView />
</motion.div> }
</AnimatePresence>
<ToolbarView isInRoom={ !landingViewVisible } />
<ModToolsView />
<RoomView />
<ChatHistoryView />
<WiredView />
<AvatarEditorView />
<AchievementsView />
<NavigatorView />
<NitrobubbleHiddenView />
<InventoryView />
<CatalogView />
<FriendsView />
<RightSideView />
<UserSettingsView />
<UserProfileView />
<GroupsView />
<CameraWidgetView />
<HelpView />
<NitropediaView />
<GuideToolView />
<HcCenterView />
<CampaignView />
<GameCenterView />
<FloorplanEditorView />
</>
);
};
@@ -0,0 +1,19 @@
import { AchievementData } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { AchievementUtilities } from '../../api';
import { BaseProps, LayoutBadgeImageView } from '../../common';
interface AchievementBadgeViewProps extends BaseProps<HTMLDivElement>
{
achievement: AchievementData;
scale?: number;
}
export const AchievementBadgeView: FC<AchievementBadgeViewProps> = props =>
{
const { achievement = null, scale = 1, ...rest } = props;
if(!achievement) return null;
return <LayoutBadgeImageView badgeCode={ AchievementUtilities.getAchievementBadgeCode(achievement) } isGrayscale={ !AchievementUtilities.getAchievementHasStarted(achievement) } scale={ scale } { ...rest } />;
};
@@ -0,0 +1,42 @@
import { FC, useEffect } from 'react';
import { AchievementCategory } from '../../api';
import { Column } from '../../common';
import { useAchievements } from '../../hooks';
import { AchievementDetailsView } from './AchievementDetailsView';
import { AchievementListView } from './achievement-list';
interface AchievementCategoryViewProps {
category: AchievementCategory;
}
export const AchievementCategoryView: FC<AchievementCategoryViewProps> = (
props,
) =>
{
const { category = null } = props;
const { selectedAchievement = null, setSelectedAchievementId = null } =
useAchievements();
useEffect(() =>
{
if(!category) return;
if(!selectedAchievement)
{
setSelectedAchievementId(
category?.achievements?.[0]?.achievementId,
);
}
}, [category, selectedAchievement, setSelectedAchievementId]);
if(!category) return null;
return (
<Column fullHeight justifyContent="between">
<AchievementListView achievements={category.achievements} />
{!!selectedAchievement && (
<AchievementDetailsView achievement={selectedAchievement} />
)}
</Column>
);
};
@@ -0,0 +1,53 @@
import { AchievementData } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { AchievementUtilities, LocalizeBadgeDescription, LocalizeBadgeName, LocalizeText } from '../../api';
import { Column, Flex, LayoutCurrencyIcon, LayoutProgressBar, Text } from '../../common';
import { AchievementBadgeView } from './AchievementBadgeView';
interface AchievementDetailsViewProps
{
achievement: AchievementData;
}
export const AchievementDetailsView: FC<AchievementDetailsViewProps> = props =>
{
const { achievement = null } = props;
if(!achievement) return null;
return (
<Flex shrink className="bg-muted rounded p-2 text-black" gap={ 2 } overflow="hidden">
<Column center gap={ 1 }>
<AchievementBadgeView achievement={ achievement } className="nitro-achievements-relative w-[40px] h-[40px] bg-no-repeat bg-center" scale={ 2 } />
<Text fontWeight="bold">
{ LocalizeText('achievements.details.level', [ 'level', 'limit' ], [ AchievementUtilities.getAchievementLevel(achievement).toString(), achievement.levelCount.toString() ]) }
</Text>
</Column>
<Column fullWidth justifyContent="center" overflow="hidden">
<div className="flex flex-col gap-1">
<Text truncate fontWeight="bold">
{ LocalizeBadgeName(AchievementUtilities.getAchievementBadgeCode(achievement)) }
</Text>
<Text textBreak>
{ LocalizeBadgeDescription(AchievementUtilities.getAchievementBadgeCode(achievement)) }
</Text>
</div>
{ ((achievement.levelRewardPoints > 0) || (achievement.scoreLimit > 0)) &&
<div className="flex flex-col gap-1">
{ (achievement.levelRewardPoints > 0) &&
<div className="flex items-center gap-1">
<Text truncate className="small">
{ LocalizeText('achievements.details.reward') }
</Text>
<Flex center className="font-bold small" gap={ 1 }>
{ achievement.levelRewardPoints }
<LayoutCurrencyIcon type={ achievement.levelRewardPointType } />
</Flex>
</div> }
{ (achievement.scoreLimit > 0) &&
<LayoutProgressBar maxProgress={ (achievement.scoreLimit + achievement.scoreAtStartOfLevel) } progress={ (achievement.currentPoints + achievement.scoreAtStartOfLevel) } text={ LocalizeText('achievements.details.progress', [ 'progress', 'limit' ], [ (achievement.currentPoints + achievement.scoreAtStartOfLevel).toString(), (achievement.scoreLimit + achievement.scoreAtStartOfLevel).toString() ]) } /> }
</div> }
</Column>
</Flex>
);
};
@@ -0,0 +1,143 @@
import
{
AddLinkEventTracker,
ILinkEventTracker,
RemoveLinkEventTracker,
} from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { AchievementUtilities, LocalizeText } from '../../api';
import { Column, LayoutImage, LayoutProgressBar, Text } from '../../common';
import { useAchievements } from '../../hooks';
import { NitroCard } from '../../layout';
import { AchievementCategoryView } from './AchievementCategoryView';
import { AchievementsCategoryListView } from './category-list';
export const AchievementsView: FC<{}> = (props) =>
{
const [isVisible, setIsVisible] = useState(false);
const {
achievementCategories = [],
selectedCategoryCode = null,
setSelectedCategoryCode = null,
achievementScore = 0,
getProgress = 0,
getMaxProgress = 0,
selectedCategory = null,
} = useAchievements();
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible((prevValue) => !prevValue);
return;
}
},
eventUrlPrefix: 'achievements/',
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
if(!isVisible) return null;
return (
<NitroCard className="w-[375px] h-[405px]" uniqueKey="achievements">
<NitroCard.Header
headerText={LocalizeText('inventory.achievements')}
onCloseClick={(event) => setIsVisible(false)}
/>
{selectedCategory && (
<div className="relative flex items-center justify-center gap-3 p-1 cursor-pointer container-fluid bg-muted">
<div
className="bg-[url('@/assets/images/achievements/back-arrow.png')] bg-center no-repeat w-[33px] h-[34px]"
onClick={(event) => setSelectedCategoryCode(null)}
/>
<Column className="!flex-grow" gap={0}>
<Text
className="text-small"
fontSize={4}
fontWeight="bold"
>
{LocalizeText(
`quests.${selectedCategory.code}.name`
)}
</Text>
<Text>
{LocalizeText(
'achievements.details.categoryprogress',
['progress', 'limit'],
[
selectedCategory.getProgress().toString(),
selectedCategory
.getMaxProgress()
.toString(),
]
)}
</Text>
</Column>
<LayoutImage
imageUrl={AchievementUtilities.getAchievementCategoryImageUrl(
selectedCategory,
null,
true
)}
/>
</div>
)}
<NitroCard.Content>
{!selectedCategory && (
<>
<AchievementsCategoryListView
categories={achievementCategories}
selectedCategoryCode={selectedCategoryCode}
setSelectedCategoryCode={setSelectedCategoryCode}
/>
<div
className="flex flex-col justify-end flex-grow gap-1"
>
<Text center small>
{LocalizeText(
'achievements.categories.score',
['score'],
[achievementScore.toString()]
)}
</Text>
<LayoutProgressBar
maxProgress={getMaxProgress}
progress={getProgress}
text={LocalizeText(
'achievements.categories.totalprogress',
['progress', 'limit'],
[
getProgress.toString(),
getMaxProgress.toString(),
]
)}
/>
</div>
</>
)}
{selectedCategory && (
<AchievementCategoryView category={selectedCategory} />
)}
</NitroCard.Content>
</NitroCard>
);
};
@@ -0,0 +1,24 @@
import { AchievementData } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { LayoutGridItem } from '../../../common';
import { useAchievements } from '../../../hooks';
import { AchievementBadgeView } from '../AchievementBadgeView';
interface AchievementListItemViewProps
{
achievement: AchievementData;
}
export const AchievementListItemView: FC<AchievementListItemViewProps> = props =>
{
const { achievement = null } = props;
const { selectedAchievement = null, setSelectedAchievementId = null } = useAchievements();
if(!achievement) return null;
return (
<LayoutGridItem itemActive={ (selectedAchievement === achievement) } itemUnseen={ (achievement.unseen > 0) } onClick={ event => setSelectedAchievementId(achievement.achievementId) }>
<AchievementBadgeView achievement={ achievement } />
</LayoutGridItem>
);
};
@@ -0,0 +1,20 @@
import { AchievementData } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { AutoGrid } from '../../../common';
import { AchievementListItemView } from './AchievementListItemView';
interface AchievementListViewProps
{
achievements: AchievementData[];
}
export const AchievementListView: FC<AchievementListViewProps> = props =>
{
const { achievements = null } = props;
return (
<AutoGrid columnCount={ 6 } columnMinHeight={ 50 } columnMinWidth={ 50 }>
{ achievements && (achievements.length > 0) && achievements.map((achievement, index) => <AchievementListItemView key={ index } achievement={ achievement } />) }
</AutoGrid>
);
};
@@ -0,0 +1,2 @@
export * from './AchievementListItemView';
export * from './AchievementListView';
@@ -0,0 +1,31 @@
import { Dispatch, FC, SetStateAction } from 'react';
import { AchievementUtilities, IAchievementCategory, LocalizeText } from '../../../api';
import { LayoutBackgroundImage, LayoutGridItem, Text } from '../../../common';
interface AchievementCategoryListItemViewProps
{
category: IAchievementCategory;
selectedCategoryCode: string;
setSelectedCategoryCode: Dispatch<SetStateAction<string>>;
}
export const AchievementsCategoryListItemView: FC<AchievementCategoryListItemViewProps> = props =>
{
const { category = null, selectedCategoryCode = null, setSelectedCategoryCode = null } = props;
if(!category) return null;
const progress = AchievementUtilities.getAchievementCategoryProgress(category);
const maxProgress = AchievementUtilities.getAchievementCategoryMaxProgress(category);
const getCategoryImage = AchievementUtilities.getAchievementCategoryImageUrl(category, progress);
const getTotalUnseen = AchievementUtilities.getAchievementCategoryTotalUnseen(category);
return (
<LayoutGridItem gap={ 1 } itemActive={ (selectedCategoryCode === category.code) } itemCount={ getTotalUnseen } itemCountMinimum={ 0 } onClick={ event => setSelectedCategoryCode(category.code) }>
<Text center fullWidth small className="pt-1">{ LocalizeText(`quests.${ category.code }.name`) }</Text>
<LayoutBackgroundImage imageUrl={ getCategoryImage } position="relative">
<Text center fullWidth position="absolute" style={ { fontSize: 12, bottom: 9 } } variant="white">{ progress } / { maxProgress }</Text>
</LayoutBackgroundImage>
</LayoutGridItem>
);
};
@@ -0,0 +1,22 @@
import { Dispatch, FC, SetStateAction } from 'react';
import { IAchievementCategory } from '../../../api';
import { AutoGrid } from '../../../common';
import { AchievementsCategoryListItemView } from './AchievementsCategoryListItemView';
interface AchievementsCategoryListViewProps
{
categories: IAchievementCategory[];
selectedCategoryCode: string;
setSelectedCategoryCode: Dispatch<SetStateAction<string>>;
}
export const AchievementsCategoryListView: FC<AchievementsCategoryListViewProps> = props =>
{
const { categories = null, selectedCategoryCode = null, setSelectedCategoryCode = null } = props;
return (
<AutoGrid columnCount={ 3 } columnMinHeight={ 100 } columnMinWidth={ 90 }>
{ categories && (categories.length > 0) && categories.map((category, index) => <AchievementsCategoryListItemView key={ index } category={ category } selectedCategoryCode={ selectedCategoryCode } setSelectedCategoryCode={ setSelectedCategoryCode } />) }
</AutoGrid>
);
};
@@ -0,0 +1,2 @@
export * from './AchievementsCategoryListItemView';
export * from './AchievementsCategoryListView';
@@ -0,0 +1,6 @@
export * from './AchievementBadgeView';
export * from './AchievementCategoryView';
export * from './AchievementDetailsView';
export * from './AchievementsView';
export * from './achievement-list';
export * from './category-list';
@@ -0,0 +1,40 @@
import { AvatarDirectionAngle } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { LayoutAvatarImageView } from '../../common';
import { useAvatarEditor } from '../../hooks';
import { AvatarEditorIcon } from './AvatarEditorIcon';
const DEFAULT_DIRECTION: number = 4;
export const AvatarEditorFigurePreviewView: FC<{}> = props =>
{
const [ direction, setDirection ] = useState<number>(DEFAULT_DIRECTION);
const { getFigureString = null } = useAvatarEditor();
const rotateFigure = (newDirection: number) =>
{
if(direction < AvatarDirectionAngle.MIN_DIRECTION)
{
newDirection = (AvatarDirectionAngle.MAX_DIRECTION + (direction + 1));
}
if(direction > AvatarDirectionAngle.MAX_DIRECTION)
{
newDirection = (direction - (AvatarDirectionAngle.MAX_DIRECTION + 1));
}
setDirection(newDirection);
};
return (
<div className="flex flex-col figure-preview-container overflow-hidden relative">
<LayoutAvatarImageView direction={ direction } figure={ getFigureString } scale={ 2 } />
<AvatarEditorIcon className="avatar-spotlight" icon="spotlight" />
<div className="avatar-shadow" />
<div className="arrow-container">
<AvatarEditorIcon icon="arrow-left" onClick={ event => rotateFigure(direction + 1) } />
<AvatarEditorIcon icon="arrow-right" onClick={ event => rotateFigure(direction - 1) } />
</div>
</div>
);
};
@@ -0,0 +1,46 @@
import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren, forwardRef } from 'react';
import { classNames } from '../../layout';
type AvatarIconType = 'male' | 'female' | 'clear' | 'sellable';
export const AvatarEditorIcon = forwardRef<HTMLDivElement, PropsWithChildren<{
icon: AvatarIconType;
selected?: boolean;
}> & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>>((props, ref) =>
{
const { icon = null, selected = false, className = null, ...rest } = props;
/*
switch (icon)
{
case 'male':
break;
case 'arrow-left':
break;
default:
//statements;
break;
}
*/
return (
<div
ref={ ref }
className={ classNames(
'nitro-avatar-editor-spritesheet',
'cursor-pointer',
`${ icon }-icon`,
selected && 'selected',
className
) }
{ ...rest } />
);
});
AvatarEditorIcon.displayName = 'AvatarEditorIcon';
@@ -0,0 +1,80 @@
import { AvatarEditorFigureCategory, AvatarFigurePartType, FigureDataContainer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { IAvatarEditorCategory } from '../../api';
import { useAvatarEditor } from '../../hooks';
import { AvatarEditorIcon } from './AvatarEditorIcon';
import { AvatarEditorFigureSetView } from './figure-set';
import { AvatarEditorPaletteSetView } from './palette-set';
export const AvatarEditorModelView: FC<{
name: string,
categories: IAvatarEditorCategory[]
}> = props =>
{
const { name = '', categories = [] } = props;
const [ didChange, setDidChange ] = useState<boolean>(false);
const [ activeSetType, setActiveSetType ] = useState<string>('');
const { maxPaletteCount = 1, gender = null, setGender = null, selectedColorParts = null, getFirstSelectableColor = null, selectEditorColor = null } = useAvatarEditor();
const activeCategory = useMemo(() =>
{
return categories.find(category => category.setType === activeSetType) ?? null;
}, [ categories, activeSetType ]);
const selectSet = useCallback((setType: string) =>
{
const selectedPalettes = selectedColorParts[setType];
if(!selectedPalettes || !selectedPalettes.length) selectEditorColor(setType, 0, getFirstSelectableColor(setType));
setActiveSetType(setType);
}, [ getFirstSelectableColor, selectEditorColor, selectedColorParts ]);
useEffect(() =>
{
if(!categories || !categories.length || !didChange) return;
selectSet(categories[0]?.setType);
setDidChange(false);
}, [ categories, didChange, selectSet ]);
useEffect(() =>
{
setDidChange(true);
}, [ categories ]);
if(!activeCategory) return null;
return (
<div className="grid grid-cols-12 gap-2 overflow-hidden">
<div className="flex flex-col col-span-2">
{ (name === AvatarEditorFigureCategory.GENERIC) &&
<>
<div className="category-item items-center justify-center cursor-pointer flex" onClick={ event => setGender(AvatarFigurePartType.MALE) }>
<AvatarEditorIcon icon="male" selected={ gender === FigureDataContainer.MALE } />
</div>
<div className="category-item items-center justify-center cursor-pointer flex" onClick={ event => setGender(AvatarFigurePartType.FEMALE) }>
<AvatarEditorIcon icon="female" selected={ gender === FigureDataContainer.FEMALE } />
</div>
</> }
{ (name !== AvatarEditorFigureCategory.GENERIC) && (categories.length > 0) && categories.map(category =>
{
return (
<div key={ category.setType } className="category-item items-center justify-center cursor-pointer flex" onClick={ event => selectSet(category.setType) }>
<AvatarEditorIcon icon={ category.setType } selected={ (activeSetType === category.setType) } />
</div>
);
}) }
</div>
<div className="flex flex-col overflow-hidden col-span-5">
<AvatarEditorFigureSetView category={ activeCategory } columnCount={ 3 } />
</div>
<div className="flex flex-col overflow-hidden col-span-5">
{ (maxPaletteCount >= 1) &&
<AvatarEditorPaletteSetView category={ activeCategory } columnCount={ 5 } paletteIndex={ 0 } /> }
{ (maxPaletteCount === 2) &&
<AvatarEditorPaletteSetView category={ activeCategory } columnCount={ 5 } paletteIndex={ 1 } /> }
</div>
</div>
);
};
@@ -0,0 +1,122 @@
import { AddLinkEventTracker, AvatarEditorFigureCategory, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker, UserFigureComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaDice, FaRedo, FaTrash } from 'react-icons/fa';
import { AvatarEditorAction, LocalizeText, SendMessageComposer } from '../../api';
import { Button, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useAvatarEditor } from '../../hooks';
import { AvatarEditorFigurePreviewView } from './AvatarEditorFigurePreviewView';
import { AvatarEditorModelView } from './AvatarEditorModelView';
import { AvatarEditorWardrobeView } from './AvatarEditorWardrobeView';
const DEFAULT_MALE_FIGURE: string = 'hr-100.hd-180-7.ch-215-66.lg-270-79.sh-305-62.ha-1002-70.wa-2007';
const DEFAULT_FEMALE_FIGURE: string = 'hr-515-33.hd-600-1.ch-635-70.lg-716-66-62.sh-735-68';
export const AvatarEditorView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState(false);
const { setIsVisible: setEditorVisibility, avatarModels, activeModelKey, setActiveModelKey, loadAvatarData, getFigureStringWithFace, gender, figureSetIds = [], randomizeCurrentFigure = null, getFigureString = null } = useAvatarEditor();
const processAction = (action: string) =>
{
switch(action)
{
case AvatarEditorAction.ACTION_RESET:
loadAvatarData(GetSessionDataManager().figure, GetSessionDataManager().gender);
return;
case AvatarEditorAction.ACTION_CLEAR:
loadAvatarData(getFigureStringWithFace(0, false), gender);
return;
case AvatarEditorAction.ACTION_RANDOMIZE:
randomizeCurrentFigure();
return;
case AvatarEditorAction.ACTION_SAVE:
SendMessageComposer(new UserFigureComposer(gender, getFigureString));
setIsVisible(false);
return;
}
};
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
return;
}
},
eventUrlPrefix: 'avatar-editor/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
setEditorVisibility(isVisible);
}, [ isVisible, setEditorVisibility ]);
if(!isVisible) return null;
return (
<NitroCardView className="w-[620px] h-[374px] nitro-avatar-editor" uniqueKey="avatar-editor">
<NitroCardHeaderView headerText={ LocalizeText('avatareditor.title') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardTabsView>
{ Object.keys(avatarModels).map(modelKey =>
{
const isActive = (activeModelKey === modelKey);
return (
<NitroCardTabsItemView key={ modelKey } isActive={ isActive } onClick={ event => setActiveModelKey(modelKey) }>
{ LocalizeText(`avatareditor.category.${ modelKey }`) }
</NitroCardTabsItemView>
);
}) }
</NitroCardTabsView>
<NitroCardContentView>
<Grid className="grid gap-2 overflow-hidden">
<div className="flex flex-col col-span-9 overflow-hidden">
{ ((activeModelKey.length > 0) && (activeModelKey !== AvatarEditorFigureCategory.WARDROBE)) &&
<AvatarEditorModelView categories={ avatarModels[activeModelKey] } name={ activeModelKey } /> }
{ (activeModelKey === AvatarEditorFigureCategory.WARDROBE) &&
<AvatarEditorWardrobeView /> }
</div>
<div className="flex flex-col col-span-3 overflow-hidden gap-1">
<AvatarEditorFigurePreviewView />
<div className="flex flex-col !flex-grow gap-1">
<div className="relative inline-flex align-middle">
<Button className="flex-auto " variant="secondary" onClick={ event => processAction(AvatarEditorAction.ACTION_RESET) }>
<FaRedo className="fa-icon" />
</Button>
<Button className="flex-auto" variant="secondary" onClick={ event => processAction(AvatarEditorAction.ACTION_CLEAR) }>
<FaTrash className="fa-icon" />
</Button>
<Button className="flex-auto" variant="secondary" onClick={ event => processAction(AvatarEditorAction.ACTION_RANDOMIZE) }>
<FaDice className="fa-icon" />
</Button>
</div>
<Button className="w-full" variant="success" onClick={ event => processAction(AvatarEditorAction.ACTION_SAVE) }>
{ LocalizeText('avatareditor.save') }
</Button>
</div>
</div>
</Grid>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,61 @@
import { GetAvatarRenderManager, IAvatarFigureContainer, SaveWardrobeOutfitMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback } from 'react';
import { GetClubMemberLevel, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../api';
import { Button, LayoutAvatarImageView, LayoutCurrencyIcon } from '../../common';
import { useAvatarEditor } from '../../hooks';
import { InfiniteGrid } from '../../layout';
export const AvatarEditorWardrobeView: FC<{}> = props =>
{
const { savedFigures = [], setSavedFigures = null, loadAvatarData = null, getFigureString = null, gender = null } = useAvatarEditor();
const hcDisabled = GetConfigurationValue<boolean>('hc.disabled', false);
const wearFigureAtIndex = useCallback((index: number) =>
{
if((index >= savedFigures.length) || (index < 0)) return;
const [ figure, gender ] = savedFigures[index];
loadAvatarData(figure.getFigureString(), gender);
}, [ savedFigures, loadAvatarData ]);
const saveFigureAtWardrobeIndex = useCallback((index: number) =>
{
if((index >= savedFigures.length) || (index < 0)) return;
const newFigures = [ ...savedFigures ];
const figure = getFigureString;
newFigures[index] = [ GetAvatarRenderManager().createFigureContainer(figure), gender ];
setSavedFigures(newFigures);
SendMessageComposer(new SaveWardrobeOutfitMessageComposer((index + 1), figure, gender));
}, [ getFigureString, gender, savedFigures, setSavedFigures ]);
return (
<InfiniteGrid columnCount={ 5 } estimateSize={ 140 } itemRender={ (item: [ IAvatarFigureContainer, string ], index: number) =>
{
const [ figureContainer, gender ] = item;
let clubLevel = 0;
if(figureContainer) clubLevel = GetAvatarRenderManager().getFigureClubLevel(figureContainer, gender);
return (
<InfiniteGrid.Item className="nitro-avatar-editor-wardrobe-figure-preview">
{ figureContainer &&
<LayoutAvatarImageView direction={ 2 } figure={ figureContainer.getFigureString() } gender={ gender } /> }
<div className="avatar-shadow" />
{ !hcDisabled && (clubLevel > 0) && <LayoutCurrencyIcon className="absolute top-1 start-1" type="hc" /> }
<div className="flex gap-1 button-container">
<Button fullWidth variant="link" onClick={ event => saveFigureAtWardrobeIndex(index) }>{ LocalizeText('avatareditor.wardrobe.save') }</Button>
{ figureContainer &&
<Button fullWidth disabled={ (clubLevel > GetClubMemberLevel()) } variant="link" onClick={ event => wearFigureAtIndex(index) }>{ LocalizeText('widget.generic_usable.button.use') }</Button> }
</div>
</InfiniteGrid.Item>
);
} } items={ savedFigures } overscan={ 5 } />
);
};
@@ -0,0 +1,56 @@
import { AvatarFigurePartType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { AvatarEditorThumbnailsHelper, GetConfigurationValue, IAvatarEditorCategoryPartItem } from '../../../api';
import { LayoutCurrencyIcon, LayoutGridItemProps } from '../../../common';
import { useAvatarEditor } from '../../../hooks';
import { InfiniteGrid } from '../../../layout';
import { AvatarEditorIcon } from '../AvatarEditorIcon';
export const AvatarEditorFigureSetItemView: FC<{
setType: string;
partItem: IAvatarEditorCategoryPartItem;
isSelected: boolean;
width?: string;
} & LayoutGridItemProps> = props =>
{
const { setType = null, partItem = null, isSelected = false, width = '100%', ...rest } = props;
const [ assetUrl, setAssetUrl ] = useState<string>('');
const { selectedColorParts = null, getFigureStringWithFace = null } = useAvatarEditor();
const isHC = !GetConfigurationValue<boolean>('hc.disabled', false) && ((partItem.partSet?.clubLevel ?? 0) > 0);
useEffect(() =>
{
if(!setType || !setType.length || !partItem) return;
const loadImage = async () =>
{
const isHC = !GetConfigurationValue<boolean>('hc.disabled', false) && ((partItem.partSet?.clubLevel ?? 0) > 0);
let url: string = null;
if(setType === AvatarFigurePartType.HEAD)
{
url = await AvatarEditorThumbnailsHelper.buildForFace(getFigureStringWithFace(partItem.id), isHC);
}
else
{
url = await AvatarEditorThumbnailsHelper.build(setType, partItem, partItem.usesColor, selectedColorParts[setType] ?? null, isHC);
}
if(url && url.length) setAssetUrl(url);
};
loadImage();
}, [ setType, partItem, selectedColorParts, getFigureStringWithFace ]);
if(!partItem) return null;
return (
<InfiniteGrid.Item itemActive={ isSelected } itemImage={ (partItem.isClear ? undefined : assetUrl) } style={ { flex: '1', backgroundPosition: (setType === AvatarFigurePartType.HEAD) ? 'center -35px' : 'center' } } { ...rest }>
{ !partItem.isClear && isHC && <LayoutCurrencyIcon className="absolute end-1 bottom-1" type="hc" /> }
{ partItem.isClear && <AvatarEditorIcon icon="clear" /> }
{ !partItem.isClear && partItem.partSet.isSellable && <AvatarEditorIcon className="end-1 bottom-1 absolute" icon="sellable" /> }
</InfiniteGrid.Item>
);
};
@@ -0,0 +1,41 @@
import { FC } from 'react';
import { IAvatarEditorCategory, IAvatarEditorCategoryPartItem } from '../../../api';
import { useAvatarEditor } from '../../../hooks';
import { InfiniteGrid } from '../../../layout';
import { AvatarEditorFigureSetItemView } from './AvatarEditorFigureSetItemView';
export const AvatarEditorFigureSetView: FC<{
category: IAvatarEditorCategory;
columnCount: number;
}> = props =>
{
const { category = null, columnCount = 3 } = props;
const { selectedParts = null, selectEditorPart } = useAvatarEditor();
const isPartItemSelected = (partItem: IAvatarEditorCategoryPartItem) =>
{
if(!category || !category.setType || !selectedParts) return false;
if(!selectedParts[category.setType])
{
if(partItem.isClear) return true;
return false;
}
const partId = selectedParts[category.setType];
return (partId === partItem.id);
};
return (
<InfiniteGrid<IAvatarEditorCategoryPartItem> columnCount={ columnCount } itemRender={ (item: IAvatarEditorCategoryPartItem) =>
{
if(!item) return null;
return (
<AvatarEditorFigureSetItemView isSelected={ isPartItemSelected(item) } partItem={ item } setType={ category.setType } width={ `calc(100% / ${ columnCount }` } onClick={ event => selectEditorPart(category.setType, item.partSet?.id ?? -1) } />
);
} } items={ category.partItems } overscan={ columnCount } />
);
};
@@ -0,0 +1,2 @@
export * from './AvatarEditorFigureSetItemView';
export * from './AvatarEditorFigureSetView';
@@ -0,0 +1,7 @@
export * from './AvatarEditorFigurePreviewView';
export * from './AvatarEditorIcon';
export * from './AvatarEditorModelView';
export * from './AvatarEditorView';
export * from './AvatarEditorWardrobeView';
export * from './figure-set';
export * from './palette-set';
@@ -0,0 +1,25 @@
import { ColorConverter, IPartColor } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { GetConfigurationValue } from '../../../api';
import { LayoutCurrencyIcon, LayoutGridItemProps } from '../../../common';
import { InfiniteGrid } from '../../../layout';
export const AvatarEditorPaletteSetItem: FC<{
setType: string;
partColor: IPartColor;
isSelected: boolean;
width?: string;
} & LayoutGridItemProps> = props =>
{
const { setType = null, partColor = null, isSelected = false, width = '100%', ...rest } = props;
if(!partColor) return null;
const isHC = !GetConfigurationValue<boolean>('hc.disabled', false) && (partColor.clubLevel > 0);
return (
<InfiniteGrid.Item itemHighlight className="clear-bg" itemActive={ isSelected } itemColor={ ColorConverter.int2rgb(partColor.rgb) } { ...rest }>
{ isHC && <LayoutCurrencyIcon className="absolute end-1 bottom-1" type="hc" /> }
</InfiniteGrid.Item>
);
};
@@ -0,0 +1,36 @@
import { IPartColor } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { IAvatarEditorCategory } from '../../../api';
import { useAvatarEditor } from '../../../hooks';
import { InfiniteGrid } from '../../../layout';
import { AvatarEditorPaletteSetItem } from './AvatarEditorPaletteSetItemView';
export const AvatarEditorPaletteSetView: FC<{
category: IAvatarEditorCategory;
paletteIndex: number;
columnCount: number;
}> = props =>
{
const { category = null, paletteIndex = -1, columnCount = 3 } = props;
const { selectedColorParts = null, selectEditorColor = null } = useAvatarEditor();
const isPartColorSelected = (partColor: IPartColor) =>
{
if(!category || !category.setType || !selectedColorParts || !selectedColorParts[category.setType] || !selectedColorParts[category.setType][paletteIndex]) return false;
const selectedColorPart = selectedColorParts[category.setType][paletteIndex];
return (selectedColorPart.id === partColor.id);
};
return (
<InfiniteGrid<IPartColor> columnCount={ columnCount } itemRender={ (item: IPartColor) =>
{
if(!item) return null;
return (
<AvatarEditorPaletteSetItem isSelected={ isPartColorSelected(item) } partColor={ item } setType={ category.setType } width={ `calc(100% / ${ columnCount }` } onClick={ event => selectEditorColor(category.setType, paletteIndex, item.id) } />
);
} } items={ category.colorItems[paletteIndex] } overscan={ columnCount } />
);
};
@@ -0,0 +1,2 @@
export * from './AvatarEditorPaletteSetItemView';
export * from './AvatarEditorPaletteSetView';
@@ -0,0 +1,111 @@
import { GetSessionDataManager, HabboClubLevelEnum} from '@nitrots/nitro-renderer';
import { Dispatch, FC, SetStateAction, useCallback, useMemo, useState } from 'react';
import { Base, Grid, Flex, NitroCardView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardContentView, Text, LayoutCurrencyIcon } from '../../common';
import { useRoom } from '../../hooks';
import { GetClubMemberLevel, GetConfigurationValue } from '../../api';
interface ItemData {
id: number;
isHcOnly: boolean;
minRank: number;
isAmbassadorOnly: boolean;
selectable: boolean;
}
interface BackgroundsViewProps {
setIsVisible: Dispatch<SetStateAction<boolean>>;
selectedBackground: number;
setSelectedBackground: Dispatch<SetStateAction<number>>;
selectedStand: number;
setSelectedStand: Dispatch<SetStateAction<number>>;
selectedOverlay: number;
setSelectedOverlay: Dispatch<SetStateAction<number>>;
}
const TABS = ['backgrounds', 'stands', 'overlays'] as const;
type TabType = typeof TABS[number];
export const BackgroundsView: FC<BackgroundsViewProps> = ({
setIsVisible,
selectedBackground,
setSelectedBackground,
selectedStand,
setSelectedStand,
selectedOverlay,
setSelectedOverlay
}) => {
const [activeTab, setActiveTab] = useState<TabType>('backgrounds');
const { roomSession } = useRoom();
const userData = useMemo(() => ({
isHcMember: GetClubMemberLevel() >= HabboClubLevelEnum.CLUB,
securityLevel: GetSessionDataManager().canChangeName,
isAmbassador: GetSessionDataManager().isAmbassador
}), []);
const processData = useCallback((configData: any[], dataType: string): ItemData[] => {
if (!configData?.length) return [];
return configData
.filter(item => {
const meetsRank = userData.securityLevel >= item.minRank;
const ambassadorEligible = !item.isAmbassadorOnly || userData.isAmbassador;
return item.isHcOnly || (meetsRank && ambassadorEligible);
})
.map(item => ({ id: item[`${dataType}Id`], ...item, selectable: !item.isHcOnly || userData.isHcMember }));
}, [userData]);
const allData = useMemo(() => ({
backgrounds: processData(GetConfigurationValue('backgrounds.data'), 'background'),
stands: processData(GetConfigurationValue('stands.data'), 'stand'),
overlays: processData(GetConfigurationValue('overlays.data'), 'overlay')
}), [processData]);
const handleSelection = useCallback((id: number) => {
if (!roomSession) return;
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay };
const currentValues = { backgrounds: selectedBackground, stands: selectedStand, overlays: selectedOverlay };
setters[activeTab](id);
const newValues = { ...currentValues, [activeTab]: id };
roomSession.sendBackgroundMessage( newValues.backgrounds, newValues.stands, newValues.overlays );
}, [activeTab, roomSession, selectedBackground, selectedStand, selectedOverlay, setSelectedBackground, setSelectedStand, setSelectedOverlay]);
const renderItem = useCallback((item: ItemData, type: string) => (
<Flex
pointer
position="relative"
key={item.id}
onClick={() => item.selectable && handleSelection(item.id)}
className={item.selectable ? '' : 'non-selectable'}
>
<Base className={`profile-${type} ${type}-${item.id}`} />
{item.isHcOnly && <LayoutCurrencyIcon position="absolute" className="top-1 end-1" type="hc" />}
</Flex>
), [handleSelection]);
return (
<NitroCardView uniqueKey="backgrounds" className="absolute min-w-[535px] max-w-[535px] min-h-[389px] max-h-[389px]">
<NitroCardHeaderView headerText="Profile Background" onCloseClick={() => setIsVisible(false)} />
<NitroCardTabsView>
{TABS.map(tab => (
<NitroCardTabsItemView
key={tab}
isActive={activeTab === tab}
onClick={() => setActiveTab(tab)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</NitroCardTabsItemView>
))}
</NitroCardTabsView>
<NitroCardContentView gap={1}>
<Text bold center>Select an Option</Text>
<Grid gap={1} columnCount={7} overflow="auto">
{allData[activeTab].map(item => renderItem(item, activeTab.slice(0, -1)))}
</Grid>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,97 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { useCamera, useNitroEvent } from '../../hooks';
import { CameraWidgetCaptureView } from './views/CameraWidgetCaptureView';
import { CameraWidgetCheckoutView } from './views/CameraWidgetCheckoutView';
import { CameraWidgetEditorView } from './views/editor/CameraWidgetEditorView';
const MODE_NONE: number = 0;
const MODE_CAPTURE: number = 1;
const MODE_EDITOR: number = 2;
const MODE_CHECKOUT: number = 3;
export const CameraWidgetView: FC<{}> = props =>
{
const [ mode, setMode ] = useState<number>(MODE_NONE);
const [ base64Url, setSavedPictureUrl ] = useState<string>(null);
const { availableEffects = [], selectedPictureIndex = -1, cameraRoll = [], setCameraRoll = null, myLevel = 0, price = { credits: 0, duckets: 0, publishDucketPrice: 0 } } = useCamera();
const processAction = (type: string) =>
{
switch(type)
{
case 'close':
setMode(MODE_NONE);
return;
case 'edit':
setMode(MODE_EDITOR);
return;
case 'delete':
setCameraRoll(prevValue =>
{
const clone = [ ...prevValue ];
clone.splice(selectedPictureIndex, 1);
return clone;
});
return;
case 'editor_cancel':
setMode(MODE_CAPTURE);
return;
}
};
const checkoutPictureUrl = (pictureUrl: string) =>
{
setSavedPictureUrl(pictureUrl);
setMode(MODE_CHECKOUT);
};
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.ENDED, event => setMode(MODE_NONE));
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setMode(MODE_CAPTURE);
return;
case 'hide':
setMode(MODE_NONE);
return;
case 'toggle':
setMode(prevValue =>
{
if(!prevValue) return MODE_CAPTURE;
else return MODE_NONE;
});
return;
}
},
eventUrlPrefix: 'camera/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
if(mode === MODE_NONE) return null;
return (
<>
{ (mode === MODE_CAPTURE) && <CameraWidgetCaptureView onClose={ () => processAction('close') } onDelete={ () => processAction('delete') } onEdit={ () => processAction('edit') } /> }
{ (mode === MODE_EDITOR) && <CameraWidgetEditorView availableEffects={ availableEffects } myLevel={ myLevel } picture={ cameraRoll[selectedPictureIndex] } onCancel={ () => processAction('editor_cancel') } onCheckout={ checkoutPictureUrl } onClose={ () => processAction('close') } /> }
{ (mode === MODE_CHECKOUT) && <CameraWidgetCheckoutView base64Url={ base64Url } price={ price } onCancelClick={ () => processAction('editor_cancel') } onCloseClick={ () => processAction('close') }></CameraWidgetCheckoutView> }
</>
);
};
@@ -0,0 +1,4 @@
export * from './CameraWidgetView';
export * from './views';
export * from './views/editor';
export * from './views/editor/effect-list';
@@ -0,0 +1,90 @@
import { GetRoomEngine, NitroRectangle, TextureUtils } from '@nitrots/nitro-renderer';
import { FC, useRef } from 'react';
import { FaTimes } from 'react-icons/fa';
import { CameraPicture, GetRoomSession, LocalizeText, PlaySound, SoundNames } from '../../../api';
import { Button, Column, DraggableWindow } from '../../../common';
import { useCamera, useNotification } from '../../../hooks';
export interface CameraWidgetCaptureViewProps
{
onClose: () => void;
onEdit: () => void;
onDelete: () => void;
}
const CAMERA_ROLL_LIMIT: number = 5;
export const CameraWidgetCaptureView: FC<CameraWidgetCaptureViewProps> = props =>
{
const { onClose = null, onEdit = null, onDelete = null } = props;
const { cameraRoll = null, setCameraRoll = null, selectedPictureIndex = -1, setSelectedPictureIndex = null } = useCamera();
const { simpleAlert = null } = useNotification();
const elementRef = useRef<HTMLDivElement>();
const selectedPicture = ((selectedPictureIndex > -1) ? cameraRoll[selectedPictureIndex] : null);
const getCameraBounds = () =>
{
if(!elementRef || !elementRef.current) return null;
const frameBounds = elementRef.current.getBoundingClientRect();
return new NitroRectangle(Math.floor(frameBounds.x), Math.floor(frameBounds.y), Math.floor(frameBounds.width), Math.floor(frameBounds.height));
};
const takePicture = async () =>
{
if(selectedPictureIndex > -1)
{
setSelectedPictureIndex(-1);
return;
}
const texture = GetRoomEngine().createTextureFromRoom(GetRoomSession().roomId, 1, getCameraBounds());
const clone = [ ...cameraRoll ];
if(clone.length >= CAMERA_ROLL_LIMIT)
{
simpleAlert(LocalizeText('camera.full.body'));
clone.pop();
}
PlaySound(SoundNames.CAMERA_SHUTTER);
clone.push(new CameraPicture(texture, await TextureUtils.generateImageUrl(texture)));
setCameraRoll(clone);
};
return (
<DraggableWindow uniqueKey="nitro-camera-capture">
<Column center className="relative" gap={ 0 }>
{ selectedPicture && <img alt="" className="absolute top-[37px] left-[10px] w-[320px] h-[320px]" src={ selectedPicture.imageUrl } /> }
<div className="relative w-[340px] h-[462px] bg-[url('@/assets/images/room-widgets/camera-widget/camera-spritesheet.png')] bg-[-1px_-1px] drag-handler">
<div className="absolute top-[8px] right-[8px] rounded-[.25rem] [box-shadow:0_0_0_1.5px_#fff] border-[2px] border-[solid] border-[#921911] bg-[repeating-linear-gradient(rgb(245,_80,_65),_rgb(245,_80,_65)_50%,_rgb(194,_48,_39)_50%,_rgb(194,_48,_39)_100%)] cursor-pointer leading-none px-[3px] py-px" onClick={ onClose }>
<FaTimes className="fa-icon" />
</div>
{ !selectedPicture && <div ref={ elementRef } className="absolute top-[37px] left-[10px] w-[320px] h-[320px] bg-[url('@/assets/images/room-widgets/camera-widget/camera-spritesheet.png')] bg-[-343px_-1px]" /> }
{ selectedPicture &&
<div className="absolute top-[37px] left-[10px] w-[320px] h-[320px] ">
<div className="bg-[rgba(0,_0,_0,_0.5)] w-full absolute bottom-0 py-2 text-center inline-flex justify-center">
<Button className="me-3" title={ LocalizeText('camera.editor.button.tooltip') } variant="success" onClick={ onEdit }>{ LocalizeText('camera.editor.button.text') }</Button>
<Button variant="danger" onClick={ onDelete }>{ LocalizeText('camera.delete.button.text') }</Button>
</div>
</div> }
<div className="flex justify-center">
<div className="w-[94px] h-[94px] cursor-pointer mt-[362px] bg-[url('@/assets/images/room-widgets/camera-widget/camera-spritesheet.png')] bg-[-343px_-321px]" title={ LocalizeText('camera.take.photo.button.tooltip') } onClick={ takePicture } />
</div>
</div>
{ (cameraRoll.length > 0) &&
<div className="w-[330px] bg-[#bab8b4] rounded-bl-[10px] rounded-br-[10px] border-[1px] border-[solid] border-[black] [box-shadow:inset_1px_0px_white,_inset_-1px_-1px_white] flex justify-center py-2">
{ cameraRoll.map((picture, index) =>
{
return <img key={ index } alt="" className="w-[56px] h-[56px] border-[1px] border-[solid] border-[black] object-contain [image-rendering:initial]" src={ picture.imageUrl } onClick={ event => setSelectedPictureIndex(index) } />;
}) }
</div> }
</Column>
</DraggableWindow>
);
};
@@ -0,0 +1,159 @@
import { CameraPublishStatusMessageEvent, CameraPurchaseOKMessageEvent, CameraStorageUrlMessageEvent, CreateLinkEvent, GetRoomEngine, PublishPhotoMessageComposer, PurchasePhotoMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../../api';
import { Button, Column, LayoutCurrencyIcon, LayoutImage, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common';
import { useMessageEvent } from '../../../hooks';
export interface CameraWidgetCheckoutViewProps
{
base64Url: string;
onCloseClick: () => void;
onCancelClick: () => void;
price: { credits: number, duckets: number, publishDucketPrice: number };
}
export const CameraWidgetCheckoutView: FC<CameraWidgetCheckoutViewProps> = props =>
{
const { base64Url = null, onCloseClick = null, onCancelClick = null, price = null } = props;
const [ pictureUrl, setPictureUrl ] = useState<string>(null);
const [ publishUrl, setPublishUrl ] = useState<string>(null);
const [ picturesBought, setPicturesBought ] = useState(0);
const [ wasPicturePublished, setWasPicturePublished ] = useState(false);
const [ isWaiting, setIsWaiting ] = useState(false);
const [ publishCooldown, setPublishCooldown ] = useState(0);
const publishDisabled = useMemo(() => GetConfigurationValue<boolean>('camera.publish.disabled', false), []);
useMessageEvent<CameraPurchaseOKMessageEvent>(CameraPurchaseOKMessageEvent, event =>
{
setPicturesBought(value => (value + 1));
setIsWaiting(false);
});
useMessageEvent<CameraPublishStatusMessageEvent>(CameraPublishStatusMessageEvent, event =>
{
const parser = event.getParser();
setPublishUrl(parser.extraDataId);
setPublishCooldown(parser.secondsToWait);
setWasPicturePublished(parser.ok);
setIsWaiting(false);
});
useMessageEvent<CameraStorageUrlMessageEvent>(CameraStorageUrlMessageEvent, event =>
{
const parser = event.getParser();
setPictureUrl(GetConfigurationValue<string>('camera.url') + '/' + parser.url);
});
const processAction = (type: string, value: string | number = null) =>
{
switch(type)
{
case 'close':
onCloseClick();
return;
case 'buy':
if(isWaiting) return;
setIsWaiting(true);
SendMessageComposer(new PurchasePhotoMessageComposer(''));
return;
case 'publish':
if(isWaiting) return;
setIsWaiting(true);
SendMessageComposer(new PublishPhotoMessageComposer());
return;
case 'cancel':
onCancelClick();
return;
}
};
useEffect(() =>
{
if(!base64Url) return;
GetRoomEngine().saveBase64AsScreenshot(base64Url);
}, [ base64Url ]);
if(!price) return null;
return (
<NitroCardView className="nitro-camera-checkout" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('camera.confirm_phase.title') } onCloseClick={ event => processAction('close') } />
<NitroCardContentView>
<div className="flex items-center justify-center">
{ (pictureUrl && pictureUrl.length) &&
<LayoutImage className="picture-preview border" imageUrl={ pictureUrl } /> }
{ (!pictureUrl || !pictureUrl.length) &&
<div className="flex items-center justify-center picture-preview border">
<Text bold>{ LocalizeText('camera.loading') }</Text>
</div> }
</div>
<div className="flex items-center bg-muted rounded p-2 justify-between">
<Column gap={ 1 } size={ publishDisabled ? 10 : 6 }>
<Text bold>
{ LocalizeText('camera.purchase.header') }
</Text>
{ ((price.credits > 0) || (price.duckets > 0)) &&
<div className="flex gap-1">
<Text>{ LocalizeText('catalog.purchase.confirmation.dialog.cost') }</Text>
{ (price.credits > 0) &&
<div className="flex gap-1">
<Text bold>{ price.credits }</Text>
<LayoutCurrencyIcon type={ -1 } />
</div> }
{ (price.duckets > 0) &&
<div className="flex gap-1">
<Text bold>{ price.duckets }</Text>
<LayoutCurrencyIcon type={ 5 } />
</div> }
</div> }
{ (picturesBought > 0) &&
<Text>
<Text bold>{ LocalizeText('camera.purchase.count.info') }</Text> { picturesBought }
<u className="ms-1 cursor-pointer" onClick={ () => CreateLinkEvent('inventory/toggle') }>{ LocalizeText('camera.open.inventory') }</u>
</Text> }
</Column>
<div className="flex items-center">
<Button disabled={ isWaiting } variant="success" onClick={ event => processAction('buy') }>{ LocalizeText(!picturesBought ? 'buy' : 'camera.buy.another.button.text') }</Button>
</div>
</div>
{ !publishDisabled &&
<div className="flex items-center justify-between bg-muted rounded p-2">
<div className="flex flex-col gap-1">
<Text bold>
{ LocalizeText(wasPicturePublished ? 'camera.publish.successful' : 'camera.publish.explanation') }
</Text>
<Text>
{ LocalizeText(wasPicturePublished ? 'camera.publish.success.short.info' : 'camera.publish.detailed.explanation') }
</Text>
{ wasPicturePublished && <a href={ publishUrl } rel="noreferrer" target="_blank">{ LocalizeText('camera.link.to.published') }</a> }
{ !wasPicturePublished && (price.publishDucketPrice > 0) &&
<div className="flex gap-1">
<Text>{ LocalizeText('catalog.purchase.confirmation.dialog.cost') }</Text>
<div className="flex gap-1">
<Text bold>{ price.publishDucketPrice }</Text>
<LayoutCurrencyIcon type={ 5 } />
</div>
</div> }
{ (publishCooldown > 0) && <div className="mt-1 text-center font-bold ">{ LocalizeText('camera.publish.wait', [ 'minutes' ], [ Math.ceil(publishCooldown / 60).toString() ]) }</div> }
</div>
{ !wasPicturePublished &&
<div className="flex align-items-end">
<Button disabled={ (isWaiting || (publishCooldown > 0)) } variant="success" onClick={ event => processAction('publish') }>
{ LocalizeText('camera.publish.button.text') }
</Button>
</div> }
</div> }
<Text center>{ LocalizeText('camera.warning.disclaimer') }</Text>
<div className="flex justify-end">
<Button onClick={ event => processAction('cancel') }>{ LocalizeText('generic.cancel') }</Button>
</div>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,68 @@
import { GetRoomEngine, RoomObjectCategory, RoomObjectVariable } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaArrowLeft, FaArrowRight } from 'react-icons/fa';
import { GetUserProfile, IPhotoData, LocalizeText } from '../../../api';
import { Flex, Grid, Text } from '../../../common';
export interface CameraWidgetShowPhotoViewProps {
currentIndex: number;
currentPhotos: IPhotoData[];
onClick?: () => void;
}
export const CameraWidgetShowPhotoView: FC<CameraWidgetShowPhotoViewProps> = props => {
const { currentIndex = -1, currentPhotos = null, onClick = null } = props;
const [imageIndex, setImageIndex] = useState(0);
const currentImage = currentPhotos && currentPhotos.length ? currentPhotos[imageIndex] : null;
const next = () => {
setImageIndex(prevValue => {
let newIndex = prevValue + 1;
if (newIndex >= currentPhotos.length) newIndex = 0;
return newIndex;
});
};
const previous = () => {
setImageIndex(prevValue => {
let newIndex = prevValue - 1;
if (newIndex < 0) newIndex = currentPhotos.length - 1;
return newIndex;
});
};
useEffect(() => {
setImageIndex(currentIndex);
}, [currentIndex]);
if (!currentImage) return null;
const getUserData = (roomId: number, objectId: number, type: string): number | string =>
{
const roomObject = GetRoomEngine().getRoomObject(roomId, objectId, RoomObjectCategory.WALL);
if (!roomObject) return;
return type == 'username' ? roomObject.model.getValue<number>(RoomObjectVariable.FURNITURE_OWNER_NAME) : roomObject.model.getValue<number>(RoomObjectVariable.FURNITURE_OWNER_ID);
}
return (
<Grid style={{ display: 'flex', flexDirection: 'column' }}>
<Flex center className="picture-preview border border-black" style={currentImage.w ? { backgroundImage: 'url(' + currentImage.w + ')' } : {}} onClick={onClick}>
{!currentImage.w && <Text bold>{LocalizeText('camera.loading')}</Text>}
</Flex>
{currentImage.m && currentImage.m.length && <Text center>{currentImage.m}</Text>}
<div className="flex items-center center justify-between">
<Text>{currentImage.n || ''}</Text>
<Text onClick={() => GetUserProfile(Number(getUserData(currentImage.s, Number(currentImage.u), 'id')))}> { getUserData(currentImage.s, Number(currentImage.u), 'username') } </Text>
<Text className="cursor-pointer" onClick={() => GetUserProfile(currentImage.oi)}>{currentImage.o}</Text>
<Text>{new Date(currentImage.t * 1000).toLocaleDateString()}</Text>
</div>
{currentPhotos.length > 1 && (
<Flex className="picture-preview-buttons">
<FaArrowLeft onClick={previous} />
<FaArrowRight className="cursor-pointer"onClick={next} />
</Flex>
)}
</Grid>
);
};
@@ -0,0 +1,222 @@
import { GetRoomCameraWidgetManager, IRoomCameraWidgetEffect, IRoomCameraWidgetSelectedEffect, NitroLogger, RoomCameraWidgetSelectedEffect } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaSave, FaSearchMinus, FaSearchPlus, FaTrash } from 'react-icons/fa';
import ReactSlider from 'react-slider';
import { CameraEditorTabs, CameraPicture, CameraPictureThumbnail, LocalizeText } from '../../../../api';
import { Button, Column, Flex, Grid, LayoutImage, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../../../common';
import { CameraWidgetEffectListView } from './effect-list';
export interface CameraWidgetEditorViewProps {
picture: CameraPicture;
availableEffects: IRoomCameraWidgetEffect[];
myLevel: number;
onClose: () => void;
onCancel: () => void;
onCheckout: (pictureUrl: string) => void;
}
const TABS: string[] = [ CameraEditorTabs.COLORMATRIX, CameraEditorTabs.COMPOSITE ];
export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props => {
const { picture = null, availableEffects = null, myLevel = 1, onClose = null, onCancel = null, onCheckout = null } = props;
const [ currentTab, setCurrentTab ] = useState(TABS[0]);
const [ selectedEffectName, setSelectedEffectName ] = useState<string>(null);
const [ selectedEffects, setSelectedEffects ] = useState<IRoomCameraWidgetSelectedEffect[]>([]);
const [ effectsThumbnails, setEffectsThumbnails ] = useState<CameraPictureThumbnail[]>([]);
const [ isZoomed, setIsZoomed ] = useState(false);
const [ currentPictureUrl, setCurrentPictureUrl ] = useState<string>('');
const isBusy = useRef<boolean>(false);
const getColorMatrixEffects = useMemo(() => {
return availableEffects.filter(effect => effect.colorMatrix);
}, [ availableEffects ]);
const getCompositeEffects = useMemo(() => {
return availableEffects.filter(effect => effect.texture);
}, [ availableEffects ]);
const getEffectList = useCallback(() => {
return currentTab === CameraEditorTabs.COLORMATRIX ? getColorMatrixEffects : getCompositeEffects;
}, [ currentTab, getColorMatrixEffects, getCompositeEffects ]);
const getSelectedEffectIndex = useCallback((name: string) => {
if (!name || !name.length || !selectedEffects || !selectedEffects.length) return -1;
return selectedEffects.findIndex(effect => effect.effect.name === name);
}, [ selectedEffects ]);
const getCurrentEffectIndex = useMemo(() => {
return getSelectedEffectIndex(selectedEffectName);
}, [ selectedEffectName, getSelectedEffectIndex ]);
const getCurrentEffect = useMemo(() => {
if (!selectedEffectName) return null;
return selectedEffects[getCurrentEffectIndex] || null;
}, [ selectedEffectName, getCurrentEffectIndex, selectedEffects ]);
const setSelectedEffectAlpha = useCallback((alpha: number) => {
const index = getCurrentEffectIndex;
if (index === -1) return;
setSelectedEffects(prevValue => {
const clone = [ ...prevValue ];
const currentEffect = clone[index];
clone[index] = new RoomCameraWidgetSelectedEffect(currentEffect.effect, alpha);
return clone;
});
}, [ getCurrentEffectIndex ]);
const processAction = useCallback((type: string, effectName: string = null) => {
switch (type) {
case 'close':
onClose();
return;
case 'cancel':
onCancel();
return;
case 'checkout':
onCheckout(currentPictureUrl);
return;
case 'change_tab':
setCurrentTab(String(effectName));
return;
case 'select_effect': {
const existingIndex = getSelectedEffectIndex(effectName);
if (existingIndex >= 0) return;
const effect = availableEffects.find(effect => effect.name === effectName);
if (!effect) return;
setSelectedEffects(prevValue => [ ...prevValue, new RoomCameraWidgetSelectedEffect(effect, 1) ]);
setSelectedEffectName(effect.name);
return;
}
case 'remove_effect': {
const existingIndex = getSelectedEffectIndex(effectName);
if (existingIndex === -1) return;
setSelectedEffects(prevValue => {
const clone = [ ...prevValue ];
clone.splice(existingIndex, 1);
return clone;
});
if (selectedEffectName === effectName) setSelectedEffectName(null);
return;
}
case 'clear_effects':
setSelectedEffectName(null);
setSelectedEffects([]);
return;
case 'download': {
(async () => {
const image = new Image();
image.src = currentPictureUrl;
const newWindow = window.open('');
newWindow.document.write(image.outerHTML);
})();
return;
}
case 'zoom':
setIsZoomed(prev => !prev);
return;
}
}, [ availableEffects, selectedEffectName, currentPictureUrl, getSelectedEffectIndex, onCancel, onCheckout, onClose ]);
useEffect(() => {
const processThumbnails = async () => {
const renderedEffects = await Promise.all(
availableEffects.map(effect =>
GetRoomCameraWidgetManager().applyEffects(picture.texture, [ new RoomCameraWidgetSelectedEffect(effect, 1) ], false)
)
);
setEffectsThumbnails(renderedEffects.map((image, index) => new CameraPictureThumbnail(availableEffects[index].name, image.src)));
};
processThumbnails();
}, [ picture, availableEffects ]);
useEffect(() => {
GetRoomCameraWidgetManager()
.applyEffects(picture.texture, selectedEffects, false) // Remove isZoomed from here
.then(imageElement => {
setCurrentPictureUrl(imageElement.src);
})
.catch(error => {
NitroLogger.error('Failed to apply effects to picture', error);
});
}, [ picture, selectedEffects ]); // Remove isZoomed from dependency array
return (
<NitroCardView className="w-[600px] h-[500px]">
<NitroCardHeaderView headerText={ LocalizeText('camera.editor.button.text') } onCloseClick={ event => processAction('close') } />
<NitroCardTabsView>
{ TABS.map(tab => (
<NitroCardTabsItemView key={ tab } isActive={ currentTab === tab } onClick={ event => processAction('change_tab', tab) }>
<i className={ 'nitro-icon icon-camera-' + tab }></i>
</NitroCardTabsItemView>
)) }
</NitroCardTabsView>
<NitroCardContentView>
<Grid>
<Column overflow="hidden" size={ 5 }>
<CameraWidgetEffectListView
myLevel={ myLevel }
selectedEffects={ selectedEffects }
effects={ getEffectList() }
thumbnails={ effectsThumbnails }
processAction={ processAction }
/>
</Column>
<Column justifyContent="between" overflow="hidden" size={ 7 }>
<Column center>
<LayoutImage
style={{
width: '320px',
height: '320px',
backgroundImage: `url(${currentPictureUrl})`,
backgroundPosition: isZoomed ? 'center' : 'top left',
backgroundSize: isZoomed ? 'contain' : 'auto', // Zoom only affects display
backgroundRepeat: 'no-repeat'
}}
/>
{ selectedEffectName && (
<Column center fullWidth gap={ 1 }>
<Text>{ LocalizeText('camera.effect.name.' + selectedEffectName) }</Text>
<ReactSlider
className="nitro-slider"
min={ 0 }
max={ 1 }
step={ 0.01 }
value={ getCurrentEffect.strength }
onChange={ event => setSelectedEffectAlpha(event) }
renderThumb={ ({ key, ...props }, state) => <div key={ key } { ...props }>{ state.valueNow }</div> }
/>
</Column>
) }
</Column>
<div className="flex justify-between">
<div className="relative inline-flex align-middle">
<Button onClick={ event => processAction('clear_effects') }>
<FaTrash className="fa-icon" />
</Button>
<Button onClick={ event => processAction('download') }>
<FaSave className="fa-icon" />
</Button>
<Button onClick={ event => processAction('zoom') }>
{ isZoomed ? <FaSearchMinus className="fa-icon" /> : <FaSearchPlus className="fa-icon" /> }
</Button>
</div>
<div className="flex gap-1">
<Button onClick={ event => processAction('cancel') }>
{ LocalizeText('generic.cancel') }
</Button>
<Button onClick={ event => processAction('checkout') }>
{ LocalizeText('camera.preview.button.text') }
</Button>
</div>
</div>
</Column>
</Grid>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,40 @@
import { IRoomCameraWidgetEffect } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { FaLock, FaTimes } from 'react-icons/fa';
import { LocalizeText } from '../../../../../api';
import { Button, LayoutGridItem, Text } from '../../../../../common';
export interface CameraWidgetEffectListItemViewProps
{
effect: IRoomCameraWidgetEffect;
thumbnailUrl: string;
isActive: boolean;
isLocked: boolean;
selectEffect: () => void;
removeEffect: () => void;
}
export const CameraWidgetEffectListItemView: FC<CameraWidgetEffectListItemViewProps> = props =>
{
const { effect = null, thumbnailUrl = null, isActive = false, isLocked = false, selectEffect = null, removeEffect = null } = props;
return (
<LayoutGridItem itemActive={ isActive } title={ LocalizeText(!isLocked ? (`camera.effect.name.${ effect.name }`) : `camera.effect.required.level ${ effect.minLevel }`) } onClick={ event => (!isActive && selectEffect()) }>
{ isActive &&
<Button className="rounded-circle remove-effect" variant="danger" onClick={ removeEffect }>
<FaTimes className="fa-icon" />
</Button> }
{ !isLocked && (thumbnailUrl && thumbnailUrl.length > 0) &&
<div className="effect-thumbnail-image border">
<img alt="" src={ thumbnailUrl } />
</div> }
{ isLocked &&
<Text bold center>
<div>
<FaLock className="fa-icon" />
</div>
{ effect.minLevel }
</Text> }
</LayoutGridItem>
);
};
@@ -0,0 +1,33 @@
import { IRoomCameraWidgetEffect, IRoomCameraWidgetSelectedEffect } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { CameraPictureThumbnail } from '../../../../../api';
import { Grid } from '../../../../../common';
import { CameraWidgetEffectListItemView } from './CameraWidgetEffectListItemView';
export interface CameraWidgetEffectListViewProps
{
myLevel: number;
selectedEffects: IRoomCameraWidgetSelectedEffect[];
effects: IRoomCameraWidgetEffect[];
thumbnails: CameraPictureThumbnail[];
processAction: (type: string, name: string) => void;
}
export const CameraWidgetEffectListView: FC<CameraWidgetEffectListViewProps> = props =>
{
const { myLevel = 0, selectedEffects = [], effects = [], thumbnails = [], processAction = null } = props;
return (
<Grid columnCount={ 3 } overflow="auto">
{ effects && (effects.length > 0) && effects.map((effect, index) =>
{
const thumbnailUrl = (thumbnails.find(thumbnail => (thumbnail.effectName === effect.name)));
const isActive = (selectedEffects.findIndex(selectedEffect => (selectedEffect.effect.name === effect.name)) > -1);
// return <CameraWidgetEffectListItemView key={ index } effect={ effect } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } removeEffect={ () => processAction('remove_effect', effect.name) } selectEffect={ () => processAction('select_effect', effect.name) } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } />;
return <CameraWidgetEffectListItemView key={ index } effect={ effect } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } selectEffect={ () => processAction('select_effect', effect.name) } removeEffect={ () => processAction('remove_effect', effect.name) } />
}) }
</Grid>
);
};
@@ -0,0 +1,2 @@
export * from './CameraWidgetEffectListItemView';
export * from './CameraWidgetEffectListView';
@@ -0,0 +1,2 @@
export * from './CameraWidgetEditorView';
export * from './effect-list';
@@ -0,0 +1,5 @@
export * from './CameraWidgetCaptureView';
export * from './CameraWidgetCheckoutView';
export * from './CameraWidgetShowPhotoView';
export * from './editor';
export * from './editor/effect-list';
@@ -0,0 +1,53 @@
import { GetRoomEngine, GetSessionDataManager } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { CalendarItemState, GetConfigurationValue, ICalendarItem } from '../../api';
import { Column, Flex, LayoutImage } from '../../common';
interface CalendarItemViewProps
{
itemId: number;
state: number;
active?: boolean;
product?: ICalendarItem;
onClick: (itemId: number) => void;
}
export const CalendarItemView: FC<CalendarItemViewProps> = props =>
{
const { itemId = -1, state = null, product = null, active = false, onClick = null } = props;
const getFurnitureIcon = (name: string) =>
{
let furniData = GetSessionDataManager().getFloorItemDataByName(name);
let url = null;
if(furniData) url = GetRoomEngine().getFurnitureFloorIconUrl(furniData.id);
else
{
furniData = GetSessionDataManager().getWallItemDataByName(name);
if(furniData) url = GetRoomEngine().getFurnitureWallIconUrl(furniData.id);
}
return url;
};
return (
<Column center fit pointer className={ `campaign-spritesheet campaign-day-generic-bg rounded calendar-item ${ active ? 'active' : '' }` } onClick={ () => onClick(itemId) }>
{ (state === CalendarItemState.STATE_UNLOCKED) &&
<Flex center className="campaign-spritesheet unlocked-bg">
<Flex center className="campaign-spritesheet campaign-opened">
{ product &&
<LayoutImage imageUrl={ product.customImage ? GetConfigurationValue<string>('image.library.url') + product.customImage : getFurnitureIcon(product.productName) } /> }
</Flex>
</Flex> }
{ (state !== CalendarItemState.STATE_UNLOCKED) &&
<Flex center className="campaign-spritesheet locked-bg">
{ (state === CalendarItemState.STATE_LOCKED_AVAILABLE) &&
<div className="campaign-spritesheet available" /> }
{ ((state === CalendarItemState.STATE_LOCKED_EXPIRED) || (state === CalendarItemState.STATE_LOCKED_FUTURE)) &&
<div className="campaign-spritesheet unavailable" /> }
</Flex> }
</Column>
);
};
@@ -0,0 +1,144 @@
import { GetSessionDataManager } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { CalendarItemState, ICalendarItem, LocalizeText } from '../../api';
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { CalendarItemView } from './CalendarItemView';
interface CalendarViewProps
{
onClose(): void;
openPackage(id: number, asStaff: boolean): void;
receivedProducts: Map<number, ICalendarItem>;
campaignName: string;
currentDay: number;
numDays: number;
openedDays: number[];
missedDays: number[];
}
const TOTAL_SHOWN_ITEMS = 5;
export const CalendarView: FC<CalendarViewProps> = props =>
{
const { onClose = null, campaignName = null, currentDay = null, numDays = null, missedDays = null, openedDays = null, openPackage = null, receivedProducts = null } = props;
const [ selectedDay, setSelectedDay ] = useState(currentDay);
const [ index, setIndex ] = useState(Math.max(0, (selectedDay - 1)));
const getDayState = (day: number) =>
{
if(openedDays.includes(day)) return CalendarItemState.STATE_UNLOCKED;
if(day > currentDay) return CalendarItemState.STATE_LOCKED_FUTURE;
if(missedDays.includes(day)) return CalendarItemState.STATE_LOCKED_EXPIRED;
return CalendarItemState.STATE_LOCKED_AVAILABLE;
};
const dayMessage = (day: number) =>
{
const state = getDayState(day);
switch(state)
{
case CalendarItemState.STATE_UNLOCKED:
return LocalizeText('campaign.calendar.info.unlocked');
case CalendarItemState.STATE_LOCKED_FUTURE:
return LocalizeText('campaign.calendar.info.future');
case CalendarItemState.STATE_LOCKED_EXPIRED:
return LocalizeText('campaign.calendar.info.expired');
default:
return LocalizeText('campaign.calendar.info.available.desktop');
}
};
const onClickNext = () =>
{
const nextDay = (selectedDay + 1);
if(nextDay === numDays) return;
setSelectedDay(nextDay);
if((index + TOTAL_SHOWN_ITEMS) < (nextDay + 1)) setIndex(index + 1);
};
const onClickPrev = () =>
{
const prevDay = (selectedDay - 1);
if(prevDay < 0) return;
setSelectedDay(prevDay);
if(index > prevDay) setIndex(index - 1);
};
const onClickItem = (item: number) =>
{
if(selectedDay === item)
{
const state = getDayState(item);
if(state === CalendarItemState.STATE_LOCKED_AVAILABLE) openPackage(item, false);
return;
}
setSelectedDay(item);
};
const forceOpen = () =>
{
const id = selectedDay;
const state = getDayState(id);
if(state !== CalendarItemState.STATE_UNLOCKED) openPackage(id, true);
};
return (
<NitroCardView className="nitro-campaign-calendar" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText(`campaign.calendar.${ campaignName }.title`) } onCloseClick={ onClose } />
<NitroCardContentView>
<Grid alignItems="center" fullHeight={ false } justifyContent="between">
<Column size={ 1 } />
<Column size={ 10 }>
<div className="flex items-center gap-1 justify-between">
<div className="flex flex-col gap-1">
<Text fontSize={ 3 }>{ LocalizeText('campaign.calendar.heading.day', [ 'number' ], [ (selectedDay + 1).toString() ]) }</Text>
<Text>{ dayMessage(selectedDay) }</Text>
</div>
<div>
{ GetSessionDataManager().isModerator &&
<Button variant="danger" onClick={ forceOpen }>Force open</Button> }
</div>
</div>
</Column>
<Column size={ 1 } />
</Grid>
<div className="flex h-full gap-2">
<div className="flex items-center justify-center">
<div className="campaign-spritesheet prev cursor-pointer" onClick={ onClickPrev } />
</div>
<Column center fullWidth>
<Grid fit columnCount={ TOTAL_SHOWN_ITEMS } gap={ 1 }>
{ [ ...Array(TOTAL_SHOWN_ITEMS) ].map((e, i) =>
{
const day = (index + i);
return (
<Column key={ i } overflow="hidden">
<CalendarItemView active={ (selectedDay === day) } itemId={ day } product={ receivedProducts.has(day) ? receivedProducts.get(day) : null } state={ getDayState(day) } onClick={ onClickItem } />
</Column>
);
}) }
</Grid>
</Column>
<div className="flex items-center justify-center">
<div className="campaign-spritesheet next cursor-pointer" onClick={ onClickNext } />
</div>
</div>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,101 @@
import { AddLinkEventTracker, CampaignCalendarData, CampaignCalendarDataMessageEvent, CampaignCalendarDoorOpenedMessageEvent, ILinkEventTracker, OpenCampaignCalendarDoorAsStaffComposer, OpenCampaignCalendarDoorComposer, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { CalendarItem, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../../hooks';
import { CalendarView } from './CalendarView';
export const CampaignView: FC<{}> = props =>
{
const [ calendarData, setCalendarData ] = useState<CampaignCalendarData>(null);
const [ lastOpenAttempt, setLastOpenAttempt ] = useState<number>(-1);
const [ receivedProducts, setReceivedProducts ] = useState<Map<number, CalendarItem>>(new Map());
const [ isCalendarOpen, setCalendarOpen ] = useState(false);
const openPackage = (id: number, asStaff = false) =>
{
if(!calendarData) return;
setLastOpenAttempt(id);
if(asStaff)
{
SendMessageComposer(new OpenCampaignCalendarDoorAsStaffComposer(calendarData.campaignName, id));
}
else
{
SendMessageComposer(new OpenCampaignCalendarDoorComposer(calendarData.campaignName, id));
}
};
useMessageEvent<CampaignCalendarDataMessageEvent>(CampaignCalendarDataMessageEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
setCalendarData(parser.calendarData);
});
useMessageEvent<CampaignCalendarDoorOpenedMessageEvent>(CampaignCalendarDoorOpenedMessageEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
const lastAttempt = lastOpenAttempt;
if(parser.doorOpened)
{
setCalendarData(prev =>
{
const copy = prev.clone();
copy.openedDays.push(lastOpenAttempt);
return copy;
});
setReceivedProducts(prev =>
{
const copy = new Map(prev);
copy.set(lastAttempt, new CalendarItem(parser.productName, parser.customImage,parser.furnitureClassName));
return copy;
});
}
setLastOpenAttempt(-1);
});
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const value = url.split('/');
if(value.length < 2) return;
switch(value[1])
{
case 'calendar':
setCalendarOpen(true);
break;
}
},
eventUrlPrefix: 'openView/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
return (
<>
{ (calendarData && isCalendarOpen) &&
<CalendarView campaignName={ calendarData.campaignName } currentDay={ calendarData.currentDay } missedDays={ calendarData.missedDays } numDays={ calendarData.campaignDays } openedDays={ calendarData.openedDays } openPackage={ openPackage } receivedProducts={ receivedProducts } onClose={ () => setCalendarOpen(false) } />
}
</>
);
};
@@ -0,0 +1,111 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect } from 'react';
import { GetConfigurationValue, LocalizeText } from '../../api';
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useCatalog } from '../../hooks';
import { CatalogIconView } from './views/catalog-icon/CatalogIconView';
import { CatalogGiftView } from './views/gift/CatalogGiftView';
import { CatalogNavigationView } from './views/navigation/CatalogNavigationView';
import { GetCatalogLayout } from './views/page/layout/GetCatalogLayout';
import { MarketplacePostOfferView } from './views/page/layout/marketplace/MarketplacePostOfferView';
export const CatalogView: FC<{}> = props =>
{
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, getNodeById } = useCatalog();
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
return;
case 'open':
if(parts.length > 2)
{
if(parts.length === 4)
{
switch(parts[2])
{
case 'offerId':
openPageByOfferId(parseInt(parts[3]));
return;
}
}
else
{
openPageByName(parts[2]);
}
}
else
{
setIsVisible(true);
}
return;
}
},
eventUrlPrefix: 'catalog/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ setIsVisible, openPageByOfferId, openPageByName ]);
return (
<>
{ isVisible &&
<NitroCardView className="w-[630px] h-[400px]" style={ GetConfigurationValue('catalog.headers') ? { width: 710 } : {} } uniqueKey="catalog">
<NitroCardHeaderView headerText={ LocalizeText('catalog.title') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardTabsView>
{ rootNode && (rootNode.children.length > 0) && rootNode.children.map(child =>
{
if(!child.isVisible) return null;
return (
<NitroCardTabsItemView key={ child.pageId } isActive={ child.isActive } onClick={ event =>
{
if(searchResult) setSearchResult(null);
activateNode(child);
} } >
<div className={ `flex items-center gap-${ GetConfigurationValue('catalog.tab.icons') ? 1 : 0 }` }>
{ GetConfigurationValue('catalog.tab.icons') && <CatalogIconView icon={ child.iconId } /> }
{ child.localization }
</div>
</NitroCardTabsItemView>
);
}) }
</NitroCardTabsView>
<NitroCardContentView>
<Grid>
{ !navigationHidden &&
<Column overflow="hidden" size={ 3 }>
{ activeNodes && (activeNodes.length > 0) &&
<CatalogNavigationView node={ activeNodes[0] } /> }
</Column> }
<Column overflow="hidden" size={ !navigationHidden ? 9 : 12 }>
{ GetCatalogLayout(currentPage, () => setNavigationHidden(true)) }
</Column>
</Grid>
</NitroCardContentView>
</NitroCardView> }
<CatalogGiftView />
<MarketplacePostOfferView />
</>
);
};
@@ -0,0 +1,10 @@
import { FC } from 'react';
export const CatalogPurchaseConfirmView: FC<{}> = props =>
{
const {} = props;
return (
<div></div>
);
};
@@ -0,0 +1,25 @@
import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue } from '../../../../api';
export interface CatalogHeaderViewProps
{
imageUrl?: string;
}
export const CatalogHeaderView: FC<CatalogHeaderViewProps> = props =>
{
const { imageUrl = null } = props;
const [ displayImageUrl, setDisplayImageUrl ] = useState('');
useEffect(() =>
{
setDisplayImageUrl(imageUrl ?? GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder'));
}, [ imageUrl ]);
return <div className="flex justify-center items-center w-full nitro-catalog-header">
<img src={ displayImageUrl } onError={ ({ currentTarget }) =>
{
currentTarget.src = GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder');
} } />
</div>;
};
@@ -0,0 +1,20 @@
import { FC, useMemo } from 'react';
import { GetConfigurationValue } from '../../../../api';
import { LayoutImage } from '../../../../common';
export interface CatalogIconViewProps
{
icon: number;
}
export const CatalogIconView: FC<CatalogIconViewProps> = props =>
{
const { icon = 0 } = props;
const getIconUrl = useMemo(() =>
{
return ((GetConfigurationValue<string>('catalog.asset.icon.url')).replace('%name%', icon.toString()));
}, [ icon ]);
return <LayoutImage imageUrl={ getIconUrl } style={ { width: 20, height: 20 } } />;
};
@@ -0,0 +1,47 @@
import { GetEventDispatcher, NitroToolbarAnimateIconEvent, RoomPreviewer, TextureUtils, ToolbarIconEnum } from '@nitrots/nitro-renderer';
import { FC, useRef } from 'react';
import { LayoutRoomPreviewerView } from '../../../../common';
import { CatalogPurchasedEvent } from '../../../../events';
import { useUiEvent } from '../../../../hooks';
export const CatalogRoomPreviewerView: FC<{
roomPreviewer: RoomPreviewer;
height?: number;
}> = props =>
{
const { roomPreviewer = null } = props;
const elementRef = useRef<HTMLDivElement>(null);
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, event =>
{
if(!elementRef) return;
const renderTexture = roomPreviewer.getRoomObjectCurrentImage();
if(!renderTexture) return;
(async () =>
{
const image = await TextureUtils.generateImage(renderTexture);
if(!image) return;
const bounds = elementRef.current.getBoundingClientRect();
const x = (bounds.x + (bounds.width / 2));
const y = (bounds.y + (bounds.height / 2));
const animateEvent = new NitroToolbarAnimateIconEvent(image, x, y);
animateEvent.iconName = ToolbarIconEnum.INVENTORY;
GetEventDispatcher().dispatchEvent(animateEvent);
})();
});
return (
<div ref={ elementRef }>
<LayoutRoomPreviewerView { ...props } />
</div>
);
};
@@ -0,0 +1,290 @@
import { GetSessionDataManager, GiftReceiverNotFoundEvent, PurchaseFromCatalogAsGiftComposer } from '@nitrots/nitro-renderer';
import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { ColorUtils, LocalizeText, MessengerFriend, ProductTypeEnum, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, FormGroup, LayoutCurrencyIcon, LayoutFurniImageView, LayoutGiftTagView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchasedEvent } from '../../../../events';
import { useCatalog, useFriends, useMessageEvent, useUiEvent } from '../../../../hooks';
import { classNames } from '../../../../layout';
export const CatalogGiftView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState<boolean>(false);
const [ pageId, setPageId ] = useState<number>(0);
const [ offerId, setOfferId ] = useState<number>(0);
const [ extraData, setExtraData ] = useState<string>('');
const [ receiverName, setReceiverName ] = useState<string>('');
const [ showMyFace, setShowMyFace ] = useState<boolean>(true);
const [ message, setMessage ] = useState<string>('');
const [ colors, setColors ] = useState<{ id: number, color: string }[]>([]);
const [ selectedBoxIndex, setSelectedBoxIndex ] = useState<number>(0);
const [ selectedRibbonIndex, setSelectedRibbonIndex ] = useState<number>(0);
const [ selectedColorId, setSelectedColorId ] = useState<number>(0);
const [ maxBoxIndex, setMaxBoxIndex ] = useState<number>(0);
const [ maxRibbonIndex, setMaxRibbonIndex ] = useState<number>(0);
const [ receiverNotFound, setReceiverNotFound ] = useState<boolean>(false);
const { catalogOptions = null } = useCatalog();
const { friends } = useFriends();
const { giftConfiguration = null } = catalogOptions;
const [ boxTypes, setBoxTypes ] = useState<number[]>([]);
const [ suggestions, setSuggestions ] = useState([]);
const [ isAutocompleteVisible, setIsAutocompleteVisible ] = useState(true);
const onClose = useCallback(() =>
{
setIsVisible(false);
setPageId(0);
setOfferId(0);
setExtraData('');
setReceiverName('');
setShowMyFace(true);
setMessage('');
setSelectedBoxIndex(0);
setSelectedRibbonIndex(0);
setIsAutocompleteVisible(false);
setSuggestions([]);
if(colors.length) setSelectedColorId(colors[0].id);
}, [ colors ]);
const isBoxDefault = useMemo(() =>
{
return giftConfiguration ? (giftConfiguration.defaultStuffTypes.findIndex(s => (s === boxTypes[selectedBoxIndex])) > -1) : false;
}, [ boxTypes, giftConfiguration, selectedBoxIndex ]);
const boxExtraData = useMemo(() =>
{
if(!giftConfiguration) return '';
return ((boxTypes[selectedBoxIndex] * 1000) + giftConfiguration.ribbonTypes[selectedRibbonIndex]).toString();
}, [ giftConfiguration, selectedBoxIndex, selectedRibbonIndex, boxTypes ]);
const isColorable = useMemo(() =>
{
if(!giftConfiguration) return false;
if(isBoxDefault) return false;
const boxType = boxTypes[selectedBoxIndex];
return (boxType === 8 || (boxType >= 3 && boxType <= 6)) ? false : true;
}, [ giftConfiguration, selectedBoxIndex, isBoxDefault, boxTypes ]);
const colourId = useMemo(() =>
{
return isBoxDefault ? boxTypes[selectedBoxIndex] : selectedColorId;
}, [ isBoxDefault, boxTypes, selectedBoxIndex, selectedColorId ]);
const allFriends = friends.filter((friend: MessengerFriend) => friend.id !== -1);
const onTextChanged = (e: ChangeEvent<HTMLInputElement>) =>
{
const value = e.target.value;
let suggestions = [];
if(value.length > 0)
{
suggestions = allFriends.sort().filter((friend: MessengerFriend) => friend.name.includes(value));
}
setReceiverName(value);
setIsAutocompleteVisible(true);
setSuggestions(suggestions);
};
const selectedReceiverName = (friendName: string) =>
{
setReceiverName(friendName);
setIsAutocompleteVisible(false);
};
const handleAction = useCallback((action: string) =>
{
switch(action)
{
case 'prev_box':
setSelectedBoxIndex(value => (value === 0 ? maxBoxIndex : value - 1));
return;
case 'next_box':
setSelectedBoxIndex(value => (value === maxBoxIndex ? 0 : value + 1));
return;
case 'prev_ribbon':
setSelectedRibbonIndex(value => (value === 0 ? maxRibbonIndex : value - 1));
return;
case 'next_ribbon':
setSelectedRibbonIndex(value => (value === maxRibbonIndex ? 0 : value + 1));
return;
case 'buy':
if(!receiverName || (receiverName.length === 0))
{
setReceiverNotFound(true);
return;
}
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(pageId, offerId, extraData, receiverName, message, colourId, selectedBoxIndex, selectedRibbonIndex, showMyFace));
return;
}
}, [ colourId, extraData, maxBoxIndex, maxRibbonIndex, message, offerId, pageId, receiverName, selectedBoxIndex, selectedRibbonIndex, showMyFace ]);
useMessageEvent<GiftReceiverNotFoundEvent>(GiftReceiverNotFoundEvent, event => setReceiverNotFound(true));
useUiEvent([
CatalogPurchasedEvent.PURCHASE_SUCCESS,
CatalogEvent.INIT_GIFT ], event =>
{
switch(event.type)
{
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
onClose();
return;
case CatalogEvent.INIT_GIFT:
const castedEvent = (event as CatalogInitGiftEvent);
onClose();
setPageId(castedEvent.pageId);
setOfferId(castedEvent.offerId);
setExtraData(castedEvent.extraData);
setIsVisible(true);
return;
}
});
useEffect(() =>
{
setReceiverNotFound(false);
}, [ receiverName ]);
const createBoxTypes = useCallback(() =>
{
if(!giftConfiguration) return;
setBoxTypes(prev =>
{
let newPrev = [ ...giftConfiguration.boxTypes ];
newPrev.push(giftConfiguration.defaultStuffTypes[Math.floor((Math.random() * (giftConfiguration.defaultStuffTypes.length - 1)))]);
setMaxBoxIndex(newPrev.length - 1);
setMaxRibbonIndex(newPrev.length - 1);
return newPrev;
});
}, [ giftConfiguration ]);
useEffect(() =>
{
if(!giftConfiguration) return;
const newColors: { id: number, color: string }[] = [];
for(const colorId of giftConfiguration.stuffTypes)
{
const giftData = GetSessionDataManager().getFloorItemData(colorId);
if(!giftData) continue;
if(giftData.colors && giftData.colors.length > 0) newColors.push({ id: colorId, color: ColorUtils.makeColorNumberHex(giftData.colors[0]) });
}
createBoxTypes();
if(newColors.length)
{
setSelectedColorId(newColors[0].id);
setColors(newColors);
}
}, [ giftConfiguration, createBoxTypes ]);
useEffect(() =>
{
if(!isVisible) return;
createBoxTypes();
}, [ createBoxTypes, isVisible ]);
if(!giftConfiguration || !giftConfiguration.isEnabled || !isVisible) return null;
const boxName = 'catalog.gift_wrapping_new.box.' + (isBoxDefault ? 'default' : boxTypes[selectedBoxIndex]);
const ribbonName = `catalog.gift_wrapping_new.ribbon.${ selectedRibbonIndex }`;
const priceText = 'catalog.gift_wrapping_new.' + (isBoxDefault ? 'freeprice' : 'price');
return (
<NitroCardView className="nitro-catalog-gift" theme="primary-slim" uniqueKey="catalog-gift">
<NitroCardHeaderView headerText={ LocalizeText('catalog.gift_wrapping.title') } onCloseClick={ onClose } />
<NitroCardContentView className="text-black">
<FormGroup column>
<Text>{ LocalizeText('catalog.gift_wrapping.receiver') }</Text>
<input className={ classNames('form-control form-control-sm', receiverNotFound && 'is-invalid') } type="text" value={ receiverName } onChange={ (e) => onTextChanged(e) } />
{ (suggestions.length > 0 && isAutocompleteVisible) &&
<Column className="autocomplete-gift-container">
{ suggestions.map((friend: MessengerFriend) => (
<div key={ friend.id } className="autocomplete-gift-item" onClick={ (e) => selectedReceiverName(friend.name) }>{ friend.name }</div>
)) }
</Column>
}
{ receiverNotFound &&
<div className="invalid-feedback">{ LocalizeText('catalog.gift_wrapping.receiver_not_found.title') }</div> }
</FormGroup>
<LayoutGiftTagView editable={ true } figure={ GetSessionDataManager().figure } message={ message } userName={ GetSessionDataManager().userName } onChange={ (value) => setMessage(value) } />
<div className="form-check">
<input checked={ showMyFace } className="form-check-input" name="showMyFace" type="checkbox" onChange={ (e) => setShowMyFace(value => !value) } />
<label className="form-check-label">{ LocalizeText('catalog.gift_wrapping.show_face.title') }</label>
</div>
<div className="items-center gap-2">
{ selectedColorId &&
<div className="gift-preview">
<LayoutFurniImageView extraData={ boxExtraData } productClassId={ colourId } productType={ ProductTypeEnum.FLOOR } />
</div> }
<div className="flex flex-col gap-1">
<div className="flex gap-2">
<div className="relative inline-flex align-middle">
<Button variant="primary" onClick={ () => handleAction('prev_box') }>
<FaChevronLeft className="fa-icon" />
</Button>
<Button variant="primary" onClick={ () => handleAction('next_box') }>
<FaChevronRight className="fa-icon" />
</Button>
</div>
<div className="flex flex-col gap-1">
<Text fontWeight="bold">{ LocalizeText(boxName) }</Text>
<div className="flex items-center gap-1">
{ LocalizeText(priceText, [ 'price' ], [ giftConfiguration.price.toString() ]) }
<LayoutCurrencyIcon type={ -1 } />
</div>
</div>
</div>
<Flex alignItems="center" className={ isColorable ? '' : 'opacity-50 pointer-events-none' } gap={ 2 }>
<div className="relative inline-flex align-middle">
<Button variant="primary" onClick={ () => handleAction('prev_ribbon') }>
<FaChevronLeft className="fa-icon" />
</Button>
<Button variant="primary" onClick={ () => handleAction('next_ribbon') }>
<FaChevronRight className="fa-icon" />
</Button>
</div>
<Text fontWeight="bold">{ LocalizeText(ribbonName) }</Text>
</Flex>
</div>
</div>
<Column className={ isColorable ? '' : 'opacity-50 pointer-events-none' } gap={ 1 }>
<Text fontWeight="bold">
{ LocalizeText('catalog.gift_wrapping.pick_color') }
</Text>
<div className="relative inline-flex align-middle w-full">
{ colors.map(color => <Button key={ color.id } active={ (color.id === selectedColorId) } disabled={ !isColorable } style={ { backgroundColor: color.color } } variant="dark" onClick={ () => setSelectedColorId(color.id) } />) }
</div>
</Column>
<div className="flex items-center justify-between">
<Button className="text-black" variant="link" onClick={ onClose }>
{ LocalizeText('cancel') }
</Button>
<Button variant="success" onClick={ () => handleAction('buy') }>
{ LocalizeText('catalog.gift_wrapping.give_gift') }
</Button>
</div>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,35 @@
import { FC } from 'react';
import { FaCaretDown, FaCaretUp } from 'react-icons/fa';
import { ICatalogNode } from '../../../../api';
import { LayoutGridItem, Text } from '../../../../common';
import { useCatalog } from '../../../../hooks';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
export interface CatalogNavigationItemViewProps
{
node: ICatalogNode;
child?: boolean;
}
export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = props =>
{
const { node = null, child = false } = props;
const { activateNode = null } = useCatalog();
return (
<div className={ child ? 'pl-[5px] border-s-2 border-[#b6bec5]' : '' }>
<LayoutGridItem className={ ' !h-[23px] bg-[#cdd3d9] !border-[0] px-[3px] py-px text-sm' } column={ false } gap={ 1 } itemActive={ node.isActive } onClick={ event => activateNode(node) }>
<CatalogIconView icon={ node.iconId } />
<Text truncate className="!flex-grow">{ node.localization }</Text>
{ node.isBranch &&
<>
{ node.isOpen && <FaCaretUp className="fa-icon" /> }
{ !node.isOpen && <FaCaretDown className="fa-icon" /> }
</> }
</LayoutGridItem>
{ node.isOpen && node.isBranch &&
<CatalogNavigationSetView child={ true } node={ node } /> }
</div>
);
};
@@ -0,0 +1,25 @@
import { FC } from 'react';
import { ICatalogNode } from '../../../../api';
import { CatalogNavigationItemView } from './CatalogNavigationItemView';
export interface CatalogNavigationSetViewProps
{
node: ICatalogNode;
child?: boolean;
}
export const CatalogNavigationSetView: FC<CatalogNavigationSetViewProps> = props =>
{
const { node = null, child = false } = props;
return (
<>
{ node && (node.children.length > 0) && node.children.map((n, index) =>
{
if(!n.isVisible) return null;
return <CatalogNavigationItemView key={ index } child={ child } node={ n } />;
}) }
</>
);
};
@@ -0,0 +1,34 @@
import { FC } from 'react';
import { ICatalogNode } from '../../../../api';
import { AutoGrid, Column } from '../../../../common';
import { useCatalog } from '../../../../hooks';
import { CatalogSearchView } from '../page/common/CatalogSearchView';
import { CatalogNavigationItemView } from './CatalogNavigationItemView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
export interface CatalogNavigationViewProps
{
node: ICatalogNode;
}
export const CatalogNavigationView: FC<CatalogNavigationViewProps> = props =>
{
const { node = null } = props;
const { searchResult = null } = useCatalog();
return (
<>
<CatalogSearchView />
<Column fullHeight className="!border-[#b6bec5] bg-[#cdd3d9] border-[2px] border-[solid] rounded p-1" overflow="hidden">
<AutoGrid columnCount={ 1 } gap={ 1 } id="nitro-catalog-main-navigation">
{ searchResult && (searchResult.filteredNodes.length > 0) && searchResult.filteredNodes.map((n, index) =>
{
return <CatalogNavigationItemView key={ index } node={ n } />;
}) }
{ !searchResult &&
<CatalogNavigationSetView node={ node } /> }
</AutoGrid>
</Column>
</>
);
};
@@ -0,0 +1,59 @@
import { MouseEventType } from '@nitrots/nitro-renderer';
import { FC, MouseEvent, useMemo, useState } from 'react';
import { IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common';
import { useCatalog, useInventoryFurni } from '../../../../../hooks';
interface CatalogGridOfferViewProps extends LayoutGridItemProps
{
offer: IPurchasableOffer;
selectOffer: (offer: IPurchasableOffer) => void;
}
export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
{
const { offer = null, selectOffer = null, itemActive = false, ...rest } = props;
const [ isMouseDown, setMouseDown ] = useState(false);
const { requestOfferToMover = null } = useCatalog();
const { isVisible = false } = useInventoryFurni();
const iconUrl = useMemo(() =>
{
if(offer.pricingModel === Offer.PRICING_MODEL_BUNDLE)
{
return null;
}
return offer.product.getIconUrl(offer);
}, [ offer ]);
const onMouseEvent = (event: MouseEvent) =>
{
switch(event.type)
{
case MouseEventType.MOUSE_DOWN:
selectOffer(offer);
setMouseDown(true);
return;
case MouseEventType.MOUSE_UP:
setMouseDown(false);
return;
case MouseEventType.ROLL_OUT:
if(!isMouseDown || !itemActive || !isVisible) return;
requestOfferToMover(offer);
return;
}
};
const product = offer.product;
if(!product) return null;
return (
<LayoutGridItem itemActive={ itemActive } itemCount={ ((offer.pricingModel === Offer.PRICING_MODEL_MULTI) ? product.productCount : 1) } itemImage={ iconUrl } itemUniqueNumber={ product.uniqueLimitedItemSeriesSize } itemUniqueSoldout={ (product.uniqueLimitedItemSeriesSize && !product.uniqueLimitedItemsLeft) } onMouseDown={ onMouseEvent } onMouseOut={ onMouseEvent } onMouseUp={ onMouseEvent } { ...rest }>
{ (offer.product.productType === ProductTypeEnum.ROBOT) &&
<LayoutAvatarImageView direction={ 3 } figure={ offer.product.extraParam } headOnly={ true } /> }
</LayoutGridItem>
);
};
@@ -0,0 +1,67 @@
import { RedeemVoucherMessageComposer, VoucherRedeemErrorMessageEvent, VoucherRedeemOkMessageEvent } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FaTag } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../../../../api';
import { Button } from '../../../../../common';
import { useMessageEvent, useNotification } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
export interface CatalogRedeemVoucherViewProps
{
text: string;
}
export const CatalogRedeemVoucherView: FC<CatalogRedeemVoucherViewProps> = props =>
{
const { text = null } = props;
const [ voucher, setVoucher ] = useState<string>('');
const [ isWaiting, setIsWaiting ] = useState(false);
const { simpleAlert = null } = useNotification();
const redeemVoucher = () =>
{
if(!voucher || !voucher.length || isWaiting) return;
SendMessageComposer(new RedeemVoucherMessageComposer(voucher));
setIsWaiting(true);
};
useMessageEvent<VoucherRedeemOkMessageEvent>(VoucherRedeemOkMessageEvent, event =>
{
const parser = event.getParser();
let message = LocalizeText('catalog.alert.voucherredeem.ok.description');
if(parser.productName) message = LocalizeText('catalog.alert.voucherredeem.ok.description.furni', [ 'productName', 'productDescription' ], [ parser.productName, parser.productDescription ]);
simpleAlert(message, null, null, null, LocalizeText('catalog.alert.voucherredeem.ok.title'));
setIsWaiting(false);
setVoucher('');
});
useMessageEvent<VoucherRedeemErrorMessageEvent>(VoucherRedeemErrorMessageEvent, event =>
{
const parser = event.getParser();
simpleAlert(LocalizeText(`catalog.alert.voucherredeem.error.description.${ parser.errorCode }`), null, null, null, LocalizeText('catalog.alert.voucherredeem.error.title'));
setIsWaiting(false);
});
return (
<div className="flex gap-1">
<NitroInput
placeholder={ text }
value={ voucher }
onChange={ event => setVoucher(event.target.value) } />
<Button disabled={ isWaiting } variant="primary" onClick={ redeemVoucher }>
<FaTag className="fa-icon" />
</Button>
</div>
);
};
@@ -0,0 +1,106 @@
import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaSearch, FaTimes } from 'react-icons/fa';
import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, GetOfferNodes, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api';
import { Button, Flex } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
export const CatalogSearchView: FC<{}> = props =>
{
const [ searchValue, setSearchValue ] = useState('');
const { currentType = null, rootNode = null, offersToNodes = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog();
useEffect(() =>
{
let search = searchValue?.toLocaleLowerCase().replace(' ', '');
if(!search || !search.length)
{
setSearchResult(null);
return;
}
const timeout = setTimeout(() =>
{
const furnitureDatas = GetSessionDataManager().getAllFurnitureData();
if(!furnitureDatas || !furnitureDatas.length) return;
const foundFurniture: IFurnitureData[] = [];
const foundFurniLines: string[] = [];
for(const furniture of furnitureDatas)
{
if((currentType === CatalogType.BUILDER) && !furniture.availableForBuildersClub) continue;
if((currentType === CatalogType.NORMAL) && furniture.excludeDynamic) continue;
const searchValues = [ furniture.className, furniture.name, furniture.description ].join(' ').replace(/ /gi, '').toLowerCase();
if((currentType === CatalogType.BUILDER) && (furniture.purchaseOfferId === -1) && (furniture.rentOfferId === -1))
{
if((furniture.furniLine !== '') && (foundFurniLines.indexOf(furniture.furniLine) < 0))
{
if(searchValues.indexOf(search) >= 0) foundFurniLines.push(furniture.furniLine);
}
}
else
{
const foundNodes = [
...GetOfferNodes(offersToNodes, furniture.purchaseOfferId),
...GetOfferNodes(offersToNodes, furniture.rentOfferId)
];
if(foundNodes.length)
{
if(searchValues.indexOf(search) >= 0) foundFurniture.push(furniture);
if(foundFurniture.length === 250) break;
}
}
}
const offers: IPurchasableOffer[] = [];
for(const furniture of foundFurniture) offers.push(new FurnitureOffer(furniture));
let nodes: ICatalogNode[] = [];
FilterCatalogNode(search, foundFurniLines, rootNode, nodes);
setSearchResult(new SearchResult(search, offers, nodes.filter(node => (node.isVisible))));
setCurrentPage((new CatalogPage(-1, 'default_3x3', new PageLocalization([], []), offers, false, 1) as ICatalogPage));
}, 300);
return () => clearTimeout(timeout);
}, [ offersToNodes, currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]);
return (
<div className="flex gap-1">
<Flex fullWidth alignItems="center" position="relative">
<NitroInput
placeholder={ LocalizeText('generic.search') }
value={ searchValue }
onChange={ event => setSearchValue(event.target.value) } />
</Flex>
{ (!searchValue || !searchValue.length) &&
<Button className="catalog-search-button" variant="primary">
<FaSearch className="fa-icon" />
</Button> }
{ searchValue && !!searchValue.length &&
<Button className="catalog-search-button" variant="primary" onClick={ event => setSearchValue('') }>
<FaTimes className="fa-icon" />
</Button> }
</div>
);
};
@@ -0,0 +1,7 @@
import { ICatalogPage } from '../../../../../api';
export interface CatalogLayoutProps
{
page: ICatalogPage;
hideNavigation: () => void;
}
@@ -0,0 +1,54 @@
import { FC } from 'react';
import { LocalizeText } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogBadgeSelectorWidgetView } from '../widgets/CatalogBadgeSelectorWidgetView';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutBadgeDisplayView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null } = useCatalog();
return (
<>
<CatalogFirstProductSelectorWidgetView />
<Grid>
<Column overflow="hidden" size={ 7 }>
<CatalogItemGridWidgetView shrink />
<Column gap={ 1 } overflow="hidden">
<Text shrink truncate fontWeight="bold">{ LocalizeText('catalog_selectbadge') }</Text>
<CatalogBadgeSelectorWidgetView />
</Column>
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
</> }
{ currentOffer &&
<>
<div className="relative overflow-hidden">
<CatalogViewProductWidgetView />
</div>
<Column className="!flex-grow" gap={ 1 }>
<CatalogLimitedItemWidgetView />
<Text truncate className="!flex-grow">{ currentOffer.localizationName }</Text>
<div className="flex justify-end">
<CatalogTotalPriceWidget alignItems="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,176 @@
import { ColorConverter } from '@nitrots/nitro-renderer';
import { FC, useMemo, useState } from 'react';
import { FaFillDrip } from 'react-icons/fa';
import { IPurchasableOffer } from '../../../../../api';
import { AutoGrid, Button, Column, Grid, LayoutGridItem, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSpinnerWidgetView } from '../widgets/CatalogSpinnerWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export interface CatalogLayoutColorGroupViewProps extends CatalogLayoutProps
{
}
export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps> = props =>
{
const { page = null } = props;
const [ colorableItems, setColorableItems ] = useState<Map<string, number[]>>(new Map<string, number[]>());
const { currentOffer = null, setCurrentOffer = null } = useCatalog();
const [ colorsShowing, setColorsShowing ] = useState<boolean>(false);
const sortByColorIndex = (a: IPurchasableOffer, b: IPurchasableOffer) =>
{
if(((!(a.product.furnitureData.colorIndex)) || (!(b.product.furnitureData.colorIndex))))
{
return 1;
}
if(a.product.furnitureData.colorIndex > b.product.furnitureData.colorIndex)
{
return 1;
}
if(a == b)
{
return 0;
}
return -1;
};
const sortyByFurnitureClassName = (a: IPurchasableOffer, b: IPurchasableOffer) =>
{
if(a.product.furnitureData.className > b.product.furnitureData.className)
{
return 1;
}
if(a == b)
{
return 0;
}
return -1;
};
const selectOffer = (offer: IPurchasableOffer) =>
{
offer.activate();
setCurrentOffer(offer);
};
const selectColor = (colorIndex: number, productName: string) =>
{
const fullName = `${ productName }*${ colorIndex }`;
const index = page.offers.findIndex(offer => offer.product.furnitureData.fullName === fullName);
if(index > -1)
{
selectOffer(page.offers[index]);
}
};
const offers = useMemo(() =>
{
const offers: IPurchasableOffer[] = [];
const addedColorableItems = new Map<string, boolean>();
const updatedColorableItems = new Map<string, number[]>();
page.offers.sort(sortByColorIndex);
page.offers.forEach(offer =>
{
if(!offer.product) return;
const furniData = offer.product.furnitureData;
if(!furniData || !furniData.hasIndexedColor)
{
offers.push(offer);
}
else
{
const name = furniData.className;
const colorIndex = furniData.colorIndex;
if(!updatedColorableItems.has(name))
{
updatedColorableItems.set(name, []);
}
let selectedColor = 0xFFFFFF;
if(furniData.colors)
{
for(let color of furniData.colors)
{
if(color !== 0xFFFFFF) // skip the white colors
{
selectedColor = color;
}
}
if(updatedColorableItems.get(name).indexOf(selectedColor) === -1)
{
updatedColorableItems.get(name)[colorIndex] = selectedColor;
}
}
if(!addedColorableItems.has(name))
{
offers.push(offer);
addedColorableItems.set(name, true);
}
}
});
offers.sort(sortyByFurnitureClassName);
setColorableItems(updatedColorableItems);
return offers;
}, [ page.offers ]);
return (
<Grid>
<Column overflow="hidden" size={ 7 }>
<AutoGrid columnCount={ 5 }>
{ (!colorsShowing || !currentOffer || !colorableItems.has(currentOffer.product.furnitureData.className)) &&
offers.map((offer, index) => <CatalogGridOfferView key={ index } itemActive={ (currentOffer && (currentOffer.product.furnitureData.hasIndexedColor ? currentOffer.product.furnitureData.className === offer.product.furnitureData.className : currentOffer.offerId === offer.offerId)) } offer={ offer } selectOffer={ selectOffer } />)
}
{ (colorsShowing && currentOffer && colorableItems.has(currentOffer.product.furnitureData.className)) &&
colorableItems.get(currentOffer.product.furnitureData.className).map((color, index) => <LayoutGridItem key={ index } itemHighlight className="clear-bg" itemActive={ (currentOffer.product.furnitureData.colorIndex === index) } itemColor={ ColorConverter.int2rgb(color) } onClick={ event => selectColor(index, currentOffer.product.furnitureData.className) } />)
}
</AutoGrid>
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
</> }
{ currentOffer &&
<>
<div className="relative overflow-hidden">
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 end-1" position="absolute" />
{ currentOffer.product.furnitureData.hasIndexedColor &&
<Button className="bottom-1 start-1" position="absolute" onClick={ event => setColorsShowing(prev => !prev) }>
<FaFillDrip className="fa-icon" />
</Button> }
</div>
<Column className="!flex-grow" gap={ 1 }>
<CatalogLimitedItemWidgetView />
<Text truncate className="!flex-grow">{ currentOffer.localizationName }</Text>
<div className="flex justify-between">
<div className="flex flex-col gap-1">
<CatalogSpinnerWidgetView />
</div>
<CatalogTotalPriceWidget alignItems="end" justifyContent="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
);
};
@@ -0,0 +1,61 @@
import { FC } from 'react';
import { GetConfigurationValue, ProductTypeEnum } from '../../../../../api';
import { Column, Flex, Grid, LayoutImage, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSpinnerWidgetView } from '../widgets/CatalogSpinnerWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null, currentPage = null } = useCatalog();
return (
<>
<Grid>
<Column overflow="hidden" size={ 7 }>
{ GetConfigurationValue('catalog.headers') &&
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
<CatalogItemGridWidgetView />
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) &&
<LayoutImage imageUrl={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
</> }
{ currentOffer &&
<>
<Flex center overflow="hidden" style={ { height: 140 } }>
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
<>
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 end-1" />
</> }
{ (currentOffer.product.productType === ProductTypeEnum.BADGE) && <CatalogAddOnBadgeWidgetView className="scale-2" /> }
</Flex>
<Column grow gap={ 1 }>
<CatalogLimitedItemWidgetView />
<Text grow truncate>{ currentOffer.localizationName }</Text>
<div className="flex justify-between">
<div className="flex flex-col gap-1">
<CatalogSpinnerWidgetView />
</div>
<CatalogTotalPriceWidget alignItems="end" justifyContent="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,48 @@
import { FC } from 'react';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogGuildBadgeWidgetView } from '../widgets/CatalogGuildBadgeWidgetView';
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null } = useCatalog();
return (
<Grid>
<Column overflow="hidden" size={ 7 }>
<CatalogItemGridWidgetView />
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
</> }
{ currentOffer &&
<>
<div className="relative overflow-hidden">
<CatalogViewProductWidgetView />
<CatalogGuildBadgeWidgetView className="bottom-1 end-1" position="absolute" />
</div>
<Column grow gap={ 1 }>
<Text truncate>{ currentOffer.localizationName }</Text>
<div className="!flex-grow">
<CatalogGuildSelectorWidgetView />
</div>
<div className="flex justify-end">
<CatalogTotalPriceWidget alignItems="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
);
};
@@ -0,0 +1,49 @@
import { CatalogGroupsComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { SendMessageComposer } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayouGuildForumView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState<number>(0);
const { currentOffer = null, setCurrentOffer = null, catalogOptions = null } = useCatalog();
const { groups = null } = catalogOptions;
useEffect(() =>
{
SendMessageComposer(new CatalogGroupsComposer());
}, [ page ]);
return (
<>
<CatalogFirstProductSelectorWidgetView />
<Grid>
<Column className="bg-muted rounded p-2 text-black" overflow="hidden" size={ 7 }>
<div className="overflow-auto" dangerouslySetInnerHTML={ { __html: page.localization.getText(1) } } />
</Column>
<Column gap={ 1 } overflow="hidden" size={ 5 }>
{ !!currentOffer &&
<>
<Column grow gap={ 1 }>
<Text truncate>{ currentOffer.localizationName }</Text>
<div className="!flex-grow">
<CatalogGuildSelectorWidgetView />
</div>
<div className="flex justify-end">
<CatalogTotalPriceWidget alignItems="end" />
</div>
<CatalogPurchaseWidgetView noGiftOption={ true } />
</Column>
</> }
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,29 @@
import { CreateLinkEvent } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { LocalizeText } from '../../../../../api';
import { Button } from '../../../../../common/Button';
import { Column } from '../../../../../common/Column';
import { Grid } from '../../../../../common/Grid';
import { LayoutImage } from '../../../../../common/layout/LayoutImage';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayouGuildFrontpageView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
return (
<Grid>
<Column className="bg-muted rounded p-2 text-black" overflow="hidden" size={ 7 }>
<div dangerouslySetInnerHTML={ { __html: page.localization.getText(2) } } />
<div className="overflow-auto" dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
<div dangerouslySetInnerHTML={ { __html: page.localization.getText(1) } } />
</Column>
<Column center overflow="hidden" size={ 5 }>
<LayoutImage imageUrl={ page.localization.getImage(1) } />
<Button onClick={ () => CreateLinkEvent('groups/create') }>
{ LocalizeText('catalog.start.guild.purchase.button') }
</Button>
</Column>
</Grid>
);
};
@@ -0,0 +1,15 @@
import { FC } from 'react';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutInfoLoyaltyView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
return (
<div className="h-full nitro-catalog-layout-info-loyalty text-black flex flex-row">
<div className="overflow-auto h-full flex flex-col info-loyalty-content">
<div dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
</div>
</div>
);
};
@@ -0,0 +1,8 @@
import { FC } from 'react';
import { CatalogLayoutProps } from './CatalogLayout.types';
import { CatalogLayoutPets3View } from './CatalogLayoutPets3View';
export const CatalogLayoutPets2View: FC<CatalogLayoutProps> = props =>
{
return <CatalogLayoutPets3View { ...props } />;
};
@@ -0,0 +1,25 @@
import { FC } from 'react';
import { Column } from '../../../../../common';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutPets3View: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const imageUrl = page.localization.getImage(1);
return (
<Column grow className="bg-muted rounded text-black p-2" overflow="hidden">
<div className="items-center gap-2">
{ imageUrl && <img alt="" src={ imageUrl } /> }
<div className="fs-5" dangerouslySetInnerHTML={ { __html: page.localization.getText(1) } } />
</div>
<Column grow alignItems="center" overflow="auto">
<div dangerouslySetInnerHTML={ { __html: page.localization.getText(2) } } />
</Column>
<div className="flex items-center">
<div className="font-bold " dangerouslySetInnerHTML={ { __html: page.localization.getText(3) } } />
</div>
</Column>
);
};
@@ -0,0 +1,116 @@
import { GetRoomAdPurchaseInfoComposer, GetUserEventCatsMessageComposer, PurchaseRoomAdMessageComposer, RoomAdPurchaseInfoEvent, RoomEntryData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../api';
import { Button, Column, Text } from '../../../../../common';
import { useCatalog, useMessageEvent, useNavigator, useRoomPromote } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ eventName, setEventName ] = useState<string>('');
const [ eventDesc, setEventDesc ] = useState<string>('');
const [ roomId, setRoomId ] = useState<number>(-1);
const [ availableRooms, setAvailableRooms ] = useState<RoomEntryData[]>([]);
const [ extended, setExtended ] = useState<boolean>(false);
const [ categoryId, setCategoryId ] = useState<number>(1);
const { categories = null } = useNavigator();
const { setIsVisible = null } = useCatalog();
const { promoteInformation, isExtended, setIsExtended } = useRoomPromote();
useEffect(() =>
{
if(isExtended)
{
setRoomId(promoteInformation.data.flatId);
setEventName(promoteInformation.data.eventName);
setEventDesc(promoteInformation.data.eventDescription);
setCategoryId(promoteInformation.data.categoryId);
setExtended(isExtended); // This is for sending to packet
setIsExtended(false); // This is from hook useRoomPromotte
}
}, [ isExtended, eventName, eventDesc, categoryId, promoteInformation.data, setIsExtended ]);
const resetData = () =>
{
setRoomId(-1);
setEventName('');
setEventDesc('');
setCategoryId(1);
setIsExtended(false);
setIsVisible(false);
};
const purchaseAd = () =>
{
const pageId = page.pageId;
const offerId = page.offers.length >= 1 ? page.offers[0].offerId : -1;
const flatId = roomId;
const name = eventName;
const desc = eventDesc;
const catId = categoryId;
SendMessageComposer(new PurchaseRoomAdMessageComposer(pageId, offerId, flatId, name, extended, desc, catId));
resetData();
};
useMessageEvent<RoomAdPurchaseInfoEvent>(RoomAdPurchaseInfoEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
setAvailableRooms(parser.rooms);
});
useEffect(() =>
{
SendMessageComposer(new GetRoomAdPurchaseInfoComposer());
// TODO: someone needs to fix this for morningstar
SendMessageComposer(new GetUserEventCatsMessageComposer());
}, []);
return (<>
<Text bold center>{ LocalizeText('roomad.catalog_header') }</Text>
<Column className="text-black" overflow="hidden" size={ 12 }>
<div>{ LocalizeText('roomad.catalog_text', [ 'duration' ], [ '120' ]) }</div>
<div className="p-1 rounded bg-muted">
<Column gap={ 2 }>
<Text bold>{ LocalizeText('navigator.category') }</Text>
<select className="form-select form-select-sm" disabled={ extended } value={ categoryId } onChange={ event => setCategoryId(parseInt(event.target.value)) }>
{ categories && categories.map((cat, index) => <option key={ index } value={ cat.id }>{ LocalizeText(cat.name) }</option>) }
</select>
</Column>
<div className="flex flex-col gap-1">
<Text bold>{ LocalizeText('roomad.catalog_name') }</Text>
<NitroInput maxLength={ 64 } readOnly={ extended } value={ eventName } onChange={ event => setEventName(event.target.value) } />
</div>
<div className="flex flex-col gap-1">
<Text bold>{ LocalizeText('roomad.catalog_description') }</Text>
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem] form-control-sm" maxLength={ 64 } readOnly={ extended } value={ eventDesc } onChange={ event => setEventDesc(event.target.value) } />
</div>
<div className="flex flex-col gap-1">
<Text bold>{ LocalizeText('roomad.catalog_roomname') }</Text>
<select className="form-select form-select-sm" disabled={ extended } value={ roomId } onChange={ event => setRoomId(parseInt(event.target.value)) }>
<option disabled value={ -1 }>{ LocalizeText('roomad.catalog_roomname') }</option>
{ availableRooms && availableRooms.map((room, index) => <option key={ index } value={ room.roomId }>{ room.roomName }</option>) }
</select>
</div>
<div className="flex flex-col gap-1">
<Button disabled={ (!eventName || !eventDesc || roomId === -1) } variant={ (!eventName || !eventDesc || roomId === -1) ? 'danger' : 'success' } onClick={ purchaseAd }>{ extended ? LocalizeText('roomad.extend.event') : LocalizeText('buy') }</Button>
</div>
</div>
</Column>
</>
);
};
interface INavigatorCategory
{
id: number;
name: string;
visible: boolean;
}
@@ -0,0 +1,41 @@
import { FC } from 'react';
import { Column, Grid, Text } from '../../../../../common';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogBundleGridWidgetView } from '../widgets/CatalogBundleGridWidgetView';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSimplePriceWidgetView } from '../widgets/CatalogSimplePriceWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutRoomBundleView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
return (
<>
<CatalogFirstProductSelectorWidgetView />
<Grid>
<Column overflow="hidden" size={ 7 }>
{ !!page.localization.getText(2) &&
<Text dangerouslySetInnerHTML={ { __html: page.localization.getText(2) } } /> }
<Column grow className="bg-muted p-2 rounded" overflow="hidden">
<CatalogBundleGridWidgetView fullWidth className="nitro-catalog-layout-bundle-grid" />
</Column>
</Column>
<Column gap={ 1 } overflow="hidden" size={ 5 }>
{ !!page.localization.getText(1) &&
<Text center small overflow="auto">{ page.localization.getText(1) }</Text> }
<Column grow gap={ 0 } overflow="hidden" position="relative">
{ !!page.localization.getImage(1) &&
<img alt="" className="!flex-grow" src={ page.localization.getImage(1) } /> }
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-0 start-0" position="absolute" />
<CatalogSimplePriceWidgetView />
</Column>
<div className="flex flex-col gap-1">
<CatalogPurchaseWidgetView />
</div>
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,41 @@
import { FC } from 'react';
import { Column, Grid, Text } from '../../../../../common';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogBundleGridWidgetView } from '../widgets/CatalogBundleGridWidgetView';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSimplePriceWidgetView } from '../widgets/CatalogSimplePriceWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutSingleBundleView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
return (
<>
<CatalogFirstProductSelectorWidgetView />
<Grid>
<Column overflow="hidden" size={ 7 }>
{ !!page.localization.getText(2) &&
<Text dangerouslySetInnerHTML={ { __html: page.localization.getText(2) } } /> }
<Column grow className="bg-muted p-2 rounded" overflow="hidden">
<CatalogBundleGridWidgetView fullWidth className="nitro-catalog-layout-bundle-grid" />
</Column>
</Column>
<Column gap={ 1 } overflow="hidden" size={ 5 }>
{ !!page.localization.getText(1) &&
<Text center small overflow="auto">{ page.localization.getText(1) }</Text> }
<Column grow gap={ 0 } overflow="hidden" position="relative">
{ !!page.localization.getImage(1) &&
<img alt="" className="!flex-grow" src={ page.localization.getImage(1) } /> }
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-0 start-0" position="absolute" />
<CatalogSimplePriceWidgetView />
</Column>
<div className="flex flex-col gap-1">
<CatalogPurchaseWidgetView />
</div>
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,113 @@
import { GetOfficialSongIdMessageComposer, GetSoundManager, MusicPriorities, OfficialSongIdMessageEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SendMessageComposer } from '../../../../../api';
import { Button, Column, Grid, LayoutImage, Text } from '../../../../../common';
import { useCatalog, useMessageEvent } from '../../../../../hooks';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSpinnerWidgetView } from '../widgets/CatalogSpinnerWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutSoundMachineView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ songId, setSongId ] = useState(-1);
const [ officialSongId, setOfficialSongId ] = useState('');
const { currentOffer = null, currentPage = null } = useCatalog();
const previewSong = (previewSongId: number) => GetSoundManager().musicController?.playSong(previewSongId, MusicPriorities.PRIORITY_PURCHASE_PREVIEW, 15, 0, 0, 0);
useMessageEvent<OfficialSongIdMessageEvent>(OfficialSongIdMessageEvent, event =>
{
const parser = event.getParser();
if(parser.officialSongId !== officialSongId) return;
setSongId(parser.songId);
});
useEffect(() =>
{
if(!currentOffer) return;
const product = currentOffer.product;
if(!product) return;
if(product.extraParam.length > 0)
{
const id = parseInt(product.extraParam);
if(id > 0)
{
setSongId(id);
}
else
{
setOfficialSongId(product.extraParam);
SendMessageComposer(new GetOfficialSongIdMessageComposer(product.extraParam));
}
}
else
{
setOfficialSongId('');
setSongId(-1);
}
return () => GetSoundManager().musicController?.stop(MusicPriorities.PRIORITY_PURCHASE_PREVIEW);
}, [ currentOffer ]);
useEffect(() =>
{
return () => GetSoundManager().musicController?.stop(MusicPriorities.PRIORITY_PURCHASE_PREVIEW);
}, []);
return (
<>
<Grid>
<Column overflow="hidden" size={ 7 }>
{ GetConfigurationValue('catalog.headers') &&
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
<CatalogItemGridWidgetView />
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) &&
<LayoutImage imageUrl={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
</> }
{ currentOffer &&
<>
<div className="flex items-center justify-center overflow-hidden" style={ { height: 140 } }>
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
<>
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 end-1" />
</> }
{ (currentOffer.product.productType === ProductTypeEnum.BADGE) && <CatalogAddOnBadgeWidgetView className="scale-2" /> }
</div>
<Column grow gap={ 1 }>
<CatalogLimitedItemWidgetView />
<Text grow truncate>{ currentOffer.localizationName }</Text>
{ songId > -1 && <Button onClick={ () => previewSong(songId) }>{ LocalizeText('play_preview_button') }</Button>
}
<div className="flex justify-between">
<div className="flex flex-col gap-1">
<CatalogSpinnerWidgetView />
</div>
<CatalogTotalPriceWidget alignItems="end" justifyContent="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
</>
);
};
@@ -0,0 +1,47 @@
import { FC, useEffect } from 'react';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSpacesWidgetView } from '../widgets/CatalogSpacesWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutSpacesView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null, roomPreviewer = null } = useCatalog();
useEffect(() =>
{
roomPreviewer.updatePreviewObjectBoundingRectangle();
}, [ roomPreviewer ]);
return (
<Grid>
<Column overflow="hidden" size={ 7 }>
<CatalogSpacesWidgetView />
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
</> }
{ currentOffer &&
<>
<div className="relative overflow-hidden">
<CatalogViewProductWidgetView />
</div>
<Column grow gap={ 1 }>
<Text grow truncate>{ currentOffer.localizationName }</Text>
<div className="flex justify-end">
<CatalogTotalPriceWidget alignItems="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
);
};
@@ -0,0 +1,56 @@
import { FC, useEffect, useState } from 'react';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ trophyText, setTrophyText ] = useState<string>('');
const { currentOffer = null, setPurchaseOptions = null } = useCatalog();
useEffect(() =>
{
if(!currentOffer) return;
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.extraData = trophyText;
return newValue;
});
}, [ currentOffer, trophyText, setPurchaseOptions ]);
return (
<Grid>
<Column overflow="hidden" size={ 7 }>
<CatalogItemGridWidgetView />
<textarea className="!flex-grow form-control w-full" defaultValue={ trophyText || '' } onChange={ event => setTrophyText(event.target.value) } />
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
</> }
{ currentOffer &&
<>
<CatalogViewProductWidgetView />
<Column grow gap={ 1 }>
<Text grow truncate>{ currentOffer.localizationName }</Text>
<div className="flex justify-end">
<CatalogTotalPriceWidget alignItems="end" />
</div>
<CatalogPurchaseWidgetView />
</Column>
</> }
</Column>
</Grid>
);
};
@@ -0,0 +1,191 @@
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { CatalogPurchaseState, LocalizeText, SendMessageComposer } from '../../../../../api';
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
import { useCatalog, usePurse, useUiEvent } from '../../../../../hooks';
import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
{
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const { currentPage = null, catalogOptions = null } = useCatalog();
const { purse = null, getCurrencyAmount = null } = usePurse();
const { clubOffers = null } = catalogOptions;
const onCatalogEvent = useCallback((event: CatalogEvent) =>
{
switch(event.type)
{
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
setPurchaseState(CatalogPurchaseState.NONE);
return;
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
setPurchaseState(CatalogPurchaseState.FAILED);
return;
}
}, []);
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, onCatalogEvent);
useUiEvent(CatalogPurchaseFailureEvent.PURCHASE_FAILED, onCatalogEvent);
const getOfferText = useCallback((offer: ClubOfferData) =>
{
let offerText = '';
if(offer.months > 0)
{
offerText = LocalizeText('catalog.vip.item.header.months', [ 'num_months' ], [ offer.months.toString() ]);
}
if(offer.extraDays > 0)
{
if(offerText !== '') offerText += ' ';
offerText += (' ' + LocalizeText('catalog.vip.item.header.days', [ 'num_days' ], [ offer.extraDays.toString() ]));
}
return offerText;
}, []);
const getPurchaseHeader = useCallback(() =>
{
if(!purse) return '';
const extensionOrSubscription = (purse.clubDays > 0 || purse.clubPeriods > 0) ? 'extension.' : 'subscription.';
const daysOrMonths = ((pendingOffer.months === 0) ? 'days' : 'months');
const daysOrMonthsText = ((pendingOffer.months === 0) ? pendingOffer.extraDays : pendingOffer.months);
const locale = LocalizeText('catalog.vip.buy.confirm.' + extensionOrSubscription + daysOrMonths);
return locale.replace('%NUM_' + daysOrMonths.toUpperCase() + '%', daysOrMonthsText.toString());
}, [ pendingOffer, purse ]);
const getPurchaseValidUntil = useCallback(() =>
{
let locale = LocalizeText('catalog.vip.buy.confirm.end_date');
locale = locale.replace('%month%', pendingOffer.month.toString());
locale = locale.replace('%day%', pendingOffer.day.toString());
locale = locale.replace('%year%', pendingOffer.year.toString());
return locale;
}, [ pendingOffer ]);
const getSubscriptionDetails = useMemo(() =>
{
const clubDays = purse.clubDays;
const clubPeriods = purse.clubPeriods;
const totalDays = (clubPeriods * 31) + clubDays;
return LocalizeText('catalog.vip.extend.info', [ 'days' ], [ totalDays.toString() ]);
}, [ purse ]);
const purchaseSubscription = useCallback(() =>
{
if(!pendingOffer) return;
setPurchaseState(CatalogPurchaseState.PURCHASE);
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
}, [ pendingOffer, currentPage ]);
const setOffer = useCallback((offer: ClubOfferData) =>
{
setPurchaseState(CatalogPurchaseState.NONE);
setPendingOffer(offer);
}, []);
const getPurchaseButton = useCallback(() =>
{
if(!pendingOffer) return null;
if(pendingOffer.priceCredits > getCurrencyAmount(-1))
{
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.title') }</Button>;
}
if(pendingOffer.priceActivityPoints > getCurrencyAmount(pendingOffer.priceActivityPointsType))
{
return <Button fullWidth variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + pendingOffer.priceActivityPointsType) }</Button>;
}
switch(purchaseState)
{
case CatalogPurchaseState.CONFIRM:
return <Button fullWidth variant="warning" onClick={ purchaseSubscription }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
case CatalogPurchaseState.PURCHASE:
return <Button disabled fullWidth variant="primary"><LayoutLoadingSpinnerView /></Button>;
case CatalogPurchaseState.FAILED:
return <Button disabled fullWidth variant="danger">{ LocalizeText('generic.failed') }</Button>;
case CatalogPurchaseState.NONE:
default:
return <Button fullWidth variant="success" onClick={ () => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('buy') }</Button>;
}
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount ]);
useEffect(() =>
{
if(!clubOffers) SendMessageComposer(new GetClubOffersMessageComposer(1));
}, [ clubOffers ]);
return (
<Grid>
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
<AutoGrid className="nitro-catalog-layout-vip-buy-grid" columnCount={ 1 }>
{ clubOffers && (clubOffers.length > 0) && clubOffers.map((offer, index) =>
{
return (
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-1" column={ false } itemActive={ pendingOffer === offer } justifyContent="between" onClick={ () => setOffer(offer) }>
<i className="icon-hc-banner" />
<Column gap={ 0 } justifyContent="end">
<Text textEnd>{ getOfferText(offer) }</Text>
<Flex gap={ 1 } justifyContent="end">
{ (offer.priceCredits > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ offer.priceCredits }</Text>
<LayoutCurrencyIcon type={ -1 } />
</Flex> }
{ (offer.priceActivityPoints > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ offer.priceActivityPoints }</Text>
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
</Flex> }
</Flex>
</Column>
</LayoutGridItem>
);
}) }
</AutoGrid>
<Text center dangerouslySetInnerHTML={ { __html: LocalizeText('catalog.vip.buy.hccenter') } }></Text>
</Column>
<Column overflow="hidden" size={ 5 }>
<Column center fullHeight overflow="hidden">
{ currentPage.localization.getImage(1) && <img alt="" src={ currentPage.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: getSubscriptionDetails } } overflow="auto" />
</Column>
{ pendingOffer &&
<Column fullWidth grow justifyContent="end">
<Flex alignItems="end">
<Column grow gap={ 0 }>
<Text fontWeight="bold">{ getPurchaseHeader() }</Text>
<Text>{ getPurchaseValidUntil() }</Text>
</Column>
<div className="flex flex-col gap-1">
{ (pendingOffer.priceCredits > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ pendingOffer.priceCredits }</Text>
<LayoutCurrencyIcon type={ -1 } />
</Flex> }
{ (pendingOffer.priceActivityPoints > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ pendingOffer.priceActivityPoints }</Text>
<LayoutCurrencyIcon type={ pendingOffer.priceActivityPointsType } />
</Flex> }
</div>
</Flex>
{ getPurchaseButton() }
</Column> }
</Column>
</Grid>
);
};
@@ -0,0 +1,80 @@
import { ICatalogPage } from '../../../../../api';
import { CatalogLayoutProps } from './CatalogLayout.types';
import { CatalogLayoutBadgeDisplayView } from './CatalogLayoutBadgeDisplayView';
import { CatalogLayoutColorGroupingView } from './CatalogLayoutColorGroupingView';
import { CatalogLayoutDefaultView } from './CatalogLayoutDefaultView';
import { CatalogLayouGuildCustomFurniView } from './CatalogLayoutGuildCustomFurniView';
import { CatalogLayouGuildForumView } from './CatalogLayoutGuildForumView';
import { CatalogLayouGuildFrontpageView } from './CatalogLayoutGuildFrontpageView';
import { CatalogLayoutInfoLoyaltyView } from './CatalogLayoutInfoLoyaltyView';
import { CatalogLayoutPets2View } from './CatalogLayoutPets2View';
import { CatalogLayoutPets3View } from './CatalogLayoutPets3View';
import { CatalogLayoutRoomAdsView } from './CatalogLayoutRoomAdsView';
import { CatalogLayoutRoomBundleView } from './CatalogLayoutRoomBundleView';
import { CatalogLayoutSingleBundleView } from './CatalogLayoutSingleBundleView';
import { CatalogLayoutSoundMachineView } from './CatalogLayoutSoundMachineView';
import { CatalogLayoutSpacesView } from './CatalogLayoutSpacesView';
import { CatalogLayoutTrophiesView } from './CatalogLayoutTrophiesView';
import { CatalogLayoutVipBuyView } from './CatalogLayoutVipBuyView';
import { CatalogLayoutFrontpage4View } from './frontpage4/CatalogLayoutFrontpage4View';
import { CatalogLayoutMarketplaceOwnItemsView } from './marketplace/CatalogLayoutMarketplaceOwnItemsView';
import { CatalogLayoutMarketplacePublicItemsView } from './marketplace/CatalogLayoutMarketplacePublicItemsView';
import { CatalogLayoutPetView } from './pets/CatalogLayoutPetView';
import { CatalogLayoutVipGiftsView } from './vip-gifts/CatalogLayoutVipGiftsView';
export const GetCatalogLayout = (page: ICatalogPage, hideNavigation: () => void) =>
{
if(!page) return null;
const layoutProps: CatalogLayoutProps = { page, hideNavigation };
switch(page.layoutCode)
{
case 'frontpage_featured':
return null;
case 'frontpage4':
return <CatalogLayoutFrontpage4View { ...layoutProps } />;
case 'pets':
return <CatalogLayoutPetView { ...layoutProps } />;
case 'pets2':
return <CatalogLayoutPets2View { ...layoutProps } />;
case 'pets3':
return <CatalogLayoutPets3View { ...layoutProps } />;
case 'vip_buy':
return <CatalogLayoutVipBuyView { ...layoutProps } />;
case 'guild_frontpage':
return <CatalogLayouGuildFrontpageView { ...layoutProps } />;
case 'guild_forum':
return <CatalogLayouGuildForumView { ...layoutProps } />;
case 'guild_custom_furni':
return <CatalogLayouGuildCustomFurniView { ...layoutProps } />;
case 'club_gifts':
return <CatalogLayoutVipGiftsView { ...layoutProps } />;
case 'marketplace_own_items':
return <CatalogLayoutMarketplaceOwnItemsView { ...layoutProps } />;
case 'marketplace':
return <CatalogLayoutMarketplacePublicItemsView { ...layoutProps } />;
case 'single_bundle':
return <CatalogLayoutSingleBundleView { ...layoutProps } />;
case 'room_bundle':
return <CatalogLayoutRoomBundleView { ...layoutProps } />;
case 'spaces_new':
return <CatalogLayoutSpacesView { ...layoutProps } />;
case 'trophies':
return <CatalogLayoutTrophiesView { ...layoutProps } />;
case 'info_loyalty':
return <CatalogLayoutInfoLoyaltyView { ...layoutProps } />;
case 'badge_display':
return <CatalogLayoutBadgeDisplayView { ...layoutProps } />;
case 'roomads':
return <CatalogLayoutRoomAdsView { ...layoutProps } />;
case 'default_3x3_color_grouping':
return <CatalogLayoutColorGroupingView { ...layoutProps } />;
case 'soundmachine':
return <CatalogLayoutSoundMachineView { ...layoutProps } />;
case 'bots':
case 'default_3x3':
default:
return <CatalogLayoutDefaultView { ...layoutProps } />;
}
};
@@ -0,0 +1,37 @@
import { FrontPageItem } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import { GetConfigurationValue } from '../../../../../../api';
import { LayoutBackgroundImage, LayoutBackgroundImageProps } from '../../../../../../common';
import { Text } from '../../../../../../common/Text';
export interface CatalogLayoutFrontPageItemViewProps extends LayoutBackgroundImageProps
{
item: FrontPageItem;
}
export const CatalogLayoutFrontPageItemView: FC<CatalogLayoutFrontPageItemViewProps> = props =>
{
const { item = null, position = 'relative', pointer = true, overflow = 'hidden', fullHeight = true, classNames = [], children = null, ...rest } = props;
const getClassNames = useMemo(() =>
{
const newClassNames: string[] = [ 'rounded', 'nitro-front-page-item' ];
if(classNames.length) newClassNames.push(...classNames);
return newClassNames;
}, [ classNames ]);
if(!item) return null;
const imageUrl = (GetConfigurationValue<string>('image.library.url') + item.itemPromoImage);
return (
<LayoutBackgroundImage classNames={ getClassNames } fullHeight={ fullHeight } imageUrl={ imageUrl } overflow={ overflow } pointer={ pointer } position={ position } { ...rest }>
<Text className="bg-dark rounded p-2 m-2 bottom-0" position="absolute" variant="white">
{ item.itemName }
</Text>
{ children }
</LayoutBackgroundImage>
);
};
@@ -0,0 +1,49 @@
import { CreateLinkEvent, FrontPageItem } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect } from 'react';
import { Column, Grid } from '../../../../../../common';
import { useCatalog } from '../../../../../../hooks';
import { CatalogRedeemVoucherView } from '../../common/CatalogRedeemVoucherView';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { CatalogLayoutFrontPageItemView } from './CatalogLayoutFrontPageItemView';
export const CatalogLayoutFrontpage4View: FC<CatalogLayoutProps> = props =>
{
const { page = null, hideNavigation = null } = props;
const { frontPageItems = [] } = useCatalog();
const selectItem = useCallback((item: FrontPageItem) =>
{
switch(item.type)
{
case FrontPageItem.ITEM_CATALOGUE_PAGE:
CreateLinkEvent(`catalog/open/${ item.catalogPageLocation }`);
return;
case FrontPageItem.ITEM_PRODUCT_OFFER:
CreateLinkEvent(`catalog/open/${ item.productOfferId }`);
return;
}
}, []);
useEffect(() =>
{
hideNavigation();
}, [ page, hideNavigation ]);
return (
<Grid>
<Column size={ 4 }>
{ frontPageItems[0] &&
<CatalogLayoutFrontPageItemView item={ frontPageItems[0] } onClick={ event => selectItem(frontPageItems[0]) } /> }
</Column>
<Column size={ 8 }>
{ frontPageItems[1] &&
<CatalogLayoutFrontPageItemView item={ frontPageItems[1] } onClick={ event => selectItem(frontPageItems[1]) } /> }
{ frontPageItems[2] &&
<CatalogLayoutFrontPageItemView item={ frontPageItems[2] } onClick={ event => selectItem(frontPageItems[2]) } /> }
{ frontPageItems[3] &&
<CatalogLayoutFrontPageItemView item={ frontPageItems[3] } onClick={ event => selectItem(frontPageItems[3]) } /> }
<CatalogRedeemVoucherView text={ page.localization.getText(1) } />
</Column>
</Grid>
);
};
@@ -0,0 +1,83 @@
import { FC, useCallback, useMemo } from 'react';
import { GetImageIconUrlForProduct, LocalizeText, MarketPlaceOfferState, MarketplaceOfferData, ProductTypeEnum } from '../../../../../../api';
import { Button, Column, LayoutGridItem, Text } from '../../../../../../common';
export interface MarketplaceItemViewProps
{
offerData: MarketplaceOfferData;
type?: number;
onClick(offerData: MarketplaceOfferData): void;
}
export const OWN_OFFER = 1;
export const PUBLIC_OFFER = 2;
export const CatalogLayoutMarketplaceItemView: FC<MarketplaceItemViewProps> = props =>
{
const { offerData = null, type = PUBLIC_OFFER, onClick = null } = props;
const getMarketplaceOfferTitle = useMemo(() =>
{
if(!offerData) return '';
// desc
return LocalizeText(((offerData.furniType === 2) ? 'wallItem' : 'roomItem') + `.name.${ offerData.furniId }`);
}, [ offerData ]);
const offerTime = useCallback( () =>
{
if(!offerData) return '';
if(offerData.status === MarketPlaceOfferState.SOLD) return LocalizeText('catalog.marketplace.offer.sold');
if(offerData.timeLeftMinutes <= 0) return LocalizeText('catalog.marketplace.offer.expired');
const time = Math.max(1, offerData.timeLeftMinutes);
const hours = Math.floor(time / 60);
const minutes = time - (hours * 60);
let text = minutes + ' ' + LocalizeText('catalog.marketplace.offer.minutes');
if(hours > 0)
{
text = hours + ' ' + LocalizeText('catalog.marketplace.offer.hours') + ' ' + text;
}
return LocalizeText('catalog.marketplace.offer.time_left', [ 'time' ], [ text ] );
}, [ offerData ]);
return (
<LayoutGridItem shrink alignItems="center" center={ false } className="p-1" column={ false }>
<Column style={ { width: 40, height: 40 } }>
<LayoutGridItem column={ false } itemImage={ GetImageIconUrlForProduct(((offerData.furniType === MarketplaceOfferData.TYPE_FLOOR) ? ProductTypeEnum.FLOOR : ProductTypeEnum.WALL), offerData.furniId, offerData.extraData) } itemUniqueNumber={ offerData.isUniqueLimitedItem ? offerData.stuffData.uniqueNumber : 0 } />
</Column>
<Column grow gap={ 0 }>
<Text fontWeight="bold">{ getMarketplaceOfferTitle }</Text>
{ (type === OWN_OFFER) &&
<>
<Text>{ LocalizeText('catalog.marketplace.offer.price_own_item', [ 'price' ], [ offerData.price.toString() ]) }</Text>
<Text>{ offerTime() }</Text>
</> }
{ (type === PUBLIC_OFFER) &&
<>
<Text>{ LocalizeText('catalog.marketplace.offer.price_public_item', [ 'price', 'average' ], [ offerData.price.toString(), ((offerData.averagePrice > 0) ? offerData.averagePrice.toString() : '-') ]) }</Text>
<Text>{ LocalizeText('catalog.marketplace.offer_count', [ 'count' ], [ offerData.offerCount.toString() ]) }</Text>
</> }
</Column>
<div className="flex flex-col gap-1">
{ ((type === OWN_OFFER) && (offerData.status !== MarketPlaceOfferState.SOLD)) &&
<Button variant="secondary" onClick={ () => onClick(offerData) }>
{ LocalizeText('catalog.marketplace.offer.pick') }
</Button> }
{ type === PUBLIC_OFFER &&
<>
<Button variant="secondary" onClick={ () => onClick(offerData) }>
{ LocalizeText('buy') }
</Button>
<Button disabled variant="secondary">
{ LocalizeText('catalog.marketplace.view_more') }
</Button>
</> }
</div>
</LayoutGridItem>
);
};
@@ -0,0 +1,102 @@
import { CancelMarketplaceOfferMessageComposer, GetMarketplaceOwnOffersMessageComposer, MarketplaceCancelOfferResultEvent, MarketplaceOwnOffersEvent, RedeemMarketplaceOfferCreditsMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { LocalizeText, MarketplaceOfferData, MarketPlaceOfferState, NotificationAlertType, SendMessageComposer } from '../../../../../../api';
import { Button, Column, Text } from '../../../../../../common';
import { useMessageEvent, useNotification } from '../../../../../../hooks';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { CatalogLayoutMarketplaceItemView, OWN_OFFER } from './CatalogLayoutMarketplaceItemView';
export const CatalogLayoutMarketplaceOwnItemsView: FC<CatalogLayoutProps> = props =>
{
const [ creditsWaiting, setCreditsWaiting ] = useState(0);
const [ offers, setOffers ] = useState<MarketplaceOfferData[]>([]);
const { simpleAlert = null } = useNotification();
useMessageEvent<MarketplaceOwnOffersEvent>(MarketplaceOwnOffersEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
const offers = parser.offers.map(offer =>
{
const newOffer = new MarketplaceOfferData(offer.offerId, offer.furniId, offer.furniType, offer.extraData, offer.stuffData, offer.price, offer.status, offer.averagePrice, offer.offerCount);
newOffer.timeLeftMinutes = offer.timeLeftMinutes;
return newOffer;
});
setCreditsWaiting(parser.creditsWaiting);
setOffers(offers);
});
useMessageEvent<MarketplaceCancelOfferResultEvent>(MarketplaceCancelOfferResultEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
if(!parser.success)
{
simpleAlert(LocalizeText('catalog.marketplace.cancel_failed'), NotificationAlertType.DEFAULT, null, null, LocalizeText('catalog.marketplace.operation_failed.topic'));
return;
}
setOffers(prevValue => prevValue.filter(value => (value.offerId !== parser.offerId)));
});
const soldOffers = useMemo(() =>
{
return offers.filter(value => (value.status === MarketPlaceOfferState.SOLD));
}, [ offers ]);
const redeemSoldOffers = useCallback(() =>
{
setOffers(prevValue =>
{
const idsToDelete = soldOffers.map(value => value.offerId);
return prevValue.filter(value => (idsToDelete.indexOf(value.offerId) === -1));
});
SendMessageComposer(new RedeemMarketplaceOfferCreditsMessageComposer());
}, [ soldOffers ]);
const takeItemBack = (offerData: MarketplaceOfferData) =>
{
SendMessageComposer(new CancelMarketplaceOfferMessageComposer(offerData.offerId));
};
useEffect(() =>
{
SendMessageComposer(new GetMarketplaceOwnOffersMessageComposer());
}, []);
return (
<Column overflow="hidden">
{ (creditsWaiting <= 0) &&
<Text center className="bg-muted rounded p-1">
{ LocalizeText('catalog.marketplace.redeem.no_sold_items') }
</Text> }
{ (creditsWaiting > 0) &&
<Column center className="bg-muted rounded p-2" gap={ 1 }>
<Text>
{ LocalizeText('catalog.marketplace.redeem.get_credits', [ 'count', 'credits' ], [ soldOffers.length.toString(), creditsWaiting.toString() ]) }
</Text>
<Button className="mt-1" onClick={ redeemSoldOffers }>
{ LocalizeText('catalog.marketplace.offer.redeem') }
</Button>
</Column> }
<Column gap={ 1 } overflow="hidden">
<Text shrink truncate fontWeight="bold">
{ LocalizeText('catalog.marketplace.items_found', [ 'count' ], [ offers.length.toString() ]) }
</Text>
<Column className="nitro-catalog-layout-marketplace-grid" overflow="auto">
{ (offers.length > 0) && offers.map(offer => <CatalogLayoutMarketplaceItemView key={ offer.offerId } offerData={ offer } type={ OWN_OFFER } onClick={ takeItemBack } />) }
</Column>
</Column>
</Column>
);
};
@@ -0,0 +1,161 @@
import { BuyMarketplaceOfferMessageComposer, GetMarketplaceOffersMessageComposer, MarketplaceBuyOfferResultEvent, MarketPlaceOffersEvent } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo, useState } from 'react';
import { IMarketplaceSearchOptions, LocalizeText, MarketplaceOfferData, MarketplaceSearchType, NotificationAlertType, SendMessageComposer } from '../../../../../../api';
import { Button, Column, Text } from '../../../../../../common';
import { useMessageEvent, useNotification, usePurse } from '../../../../../../hooks';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { CatalogLayoutMarketplaceItemView, PUBLIC_OFFER } from './CatalogLayoutMarketplaceItemView';
import { SearchFormView } from './CatalogLayoutMarketplaceSearchFormView';
const SORT_TYPES_VALUE = [ 1, 2 ];
const SORT_TYPES_ACTIVITY = [ 3, 4, 5, 6 ];
const SORT_TYPES_ADVANCED = [ 1, 2, 3, 4, 5, 6 ];
export interface CatalogLayoutMarketplacePublicItemsViewProps extends CatalogLayoutProps
{
}
export const CatalogLayoutMarketplacePublicItemsView: FC<CatalogLayoutMarketplacePublicItemsViewProps> = props =>
{
const [ searchType, setSearchType ] = useState(MarketplaceSearchType.BY_ACTIVITY);
const [ totalItemsFound, setTotalItemsFound ] = useState(0);
const [ offers, setOffers ] = useState(new Map<number, MarketplaceOfferData>());
const [ lastSearch, setLastSearch ] = useState<IMarketplaceSearchOptions>({ minPrice: -1, maxPrice: -1, query: '', type: 3 });
const { getCurrencyAmount = null } = usePurse();
const { simpleAlert = null, showConfirm = null } = useNotification();
const requestOffers = useCallback((options: IMarketplaceSearchOptions) =>
{
setLastSearch(options);
SendMessageComposer(new GetMarketplaceOffersMessageComposer(options.minPrice, options.maxPrice, options.query, options.type));
}, []);
const getSortTypes = useMemo(() =>
{
switch(searchType)
{
case MarketplaceSearchType.BY_ACTIVITY:
return SORT_TYPES_ACTIVITY;
case MarketplaceSearchType.BY_VALUE:
return SORT_TYPES_VALUE;
case MarketplaceSearchType.ADVANCED:
return SORT_TYPES_ADVANCED;
}
return [];
}, [ searchType ]);
const purchaseItem = useCallback((offerData: MarketplaceOfferData) =>
{
if(offerData.price > getCurrencyAmount(-1))
{
simpleAlert(LocalizeText('catalog.alert.notenough.credits.description'), NotificationAlertType.DEFAULT, null, null, LocalizeText('catalog.alert.notenough.title'));
return;
}
const offerId = offerData.offerId;
showConfirm(LocalizeText('catalog.marketplace.confirm_header'), () =>
{
SendMessageComposer(new BuyMarketplaceOfferMessageComposer(offerId));
},
null, null, null, LocalizeText('catalog.marketplace.confirm_title'));
}, [ getCurrencyAmount, simpleAlert, showConfirm ]);
useMessageEvent<MarketPlaceOffersEvent>(MarketPlaceOffersEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
const latestOffers = new Map<number, MarketplaceOfferData>();
parser.offers.forEach(entry =>
{
const offerEntry = new MarketplaceOfferData(entry.offerId, entry.furniId, entry.furniType, entry.extraData, entry.stuffData, entry.price, entry.status, entry.averagePrice, entry.offerCount);
offerEntry.timeLeftMinutes = entry.timeLeftMinutes;
latestOffers.set(entry.offerId, offerEntry);
});
setTotalItemsFound(parser.totalItemsFound);
setOffers(latestOffers);
});
useMessageEvent<MarketplaceBuyOfferResultEvent>(MarketplaceBuyOfferResultEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
switch(parser.result)
{
case 1:
requestOffers(lastSearch);
break;
case 2:
setOffers(prev =>
{
const newVal = new Map(prev);
newVal.delete(parser.requestedOfferId);
return newVal;
});
simpleAlert(LocalizeText('catalog.marketplace.not_available_header'), NotificationAlertType.DEFAULT, null, null, LocalizeText('catalog.marketplace.not_available_title'));
break;
case 3:
// our shit was updated
// todo: some dialogue modal
setOffers(prev =>
{
const newVal = new Map(prev);
const item = newVal.get(parser.requestedOfferId);
if(item)
{
item.offerId = parser.offerId;
item.price = parser.newPrice;
item.offerCount--;
newVal.set(item.offerId, item);
}
newVal.delete(parser.requestedOfferId);
return newVal;
});
showConfirm(LocalizeText('catalog.marketplace.confirm_higher_header') +
'\n' + LocalizeText('catalog.marketplace.confirm_price', [ 'price' ], [ parser.newPrice.toString() ]), () =>
{
SendMessageComposer(new BuyMarketplaceOfferMessageComposer(parser.offerId));
},
null, null, null, LocalizeText('catalog.marketplace.confirm_higher_title'));
break;
case 4:
simpleAlert(LocalizeText('catalog.alert.notenough.credits.description'), NotificationAlertType.DEFAULT, null, null, LocalizeText('catalog.alert.notenough.title'));
break;
}
});
return (
<>
<div className="relative inline-flex align-middle">
<Button active={ (searchType === MarketplaceSearchType.BY_ACTIVITY) } onClick={ () => setSearchType(MarketplaceSearchType.BY_ACTIVITY) }>
{ LocalizeText('catalog.marketplace.search_by_activity') }
</Button>
<Button active={ (searchType === MarketplaceSearchType.BY_VALUE) } onClick={ () => setSearchType(MarketplaceSearchType.BY_VALUE) }>
{ LocalizeText('catalog.marketplace.search_by_value') }
</Button>
<Button active={ (searchType === MarketplaceSearchType.ADVANCED) } onClick={ () => setSearchType(MarketplaceSearchType.ADVANCED) }>
{ LocalizeText('catalog.marketplace.search_advanced') }
</Button>
</div>
<SearchFormView searchType={ searchType } sortTypes={ getSortTypes } onSearch={ requestOffers } />
<Column gap={ 1 } overflow="hidden">
<Text shrink truncate fontWeight="bold">
{ LocalizeText('catalog.marketplace.items_found', [ 'count' ], [ offers.size.toString() ]) }
</Text>
<Column className="nitro-catalog-layout-marketplace-grid" overflow="auto">
{
Array.from(offers.values()).map((entry, index) => <CatalogLayoutMarketplaceItemView key={ index } offerData={ entry } type={ PUBLIC_OFFER } onClick={ purchaseItem } />)
}
</Column>
</Column>
</>
);
};
@@ -0,0 +1,86 @@
import { FC, useCallback, useEffect, useState } from 'react';
import { IMarketplaceSearchOptions, LocalizeText, MarketplaceSearchType } from '../../../../../../api';
import { Button, Text } from '../../../../../../common';
import { NitroInput } from '../../../../../../layout';
export interface SearchFormViewProps
{
searchType: number;
sortTypes: number[];
onSearch(options: IMarketplaceSearchOptions): void;
}
export const SearchFormView: FC<SearchFormViewProps> = props =>
{
const { searchType = null, sortTypes = null, onSearch = null } = props;
const [ sortType, setSortType ] = useState(sortTypes ? sortTypes[0] : 3); // first item of SORT_TYPES_ACTIVITY
const [ searchQuery, setSearchQuery ] = useState('');
const [ min, setMin ] = useState(0);
const [ max, setMax ] = useState(0);
const onSortTypeChange = useCallback((sortType: number) =>
{
setSortType(sortType);
if((searchType === MarketplaceSearchType.BY_ACTIVITY) || (searchType === MarketplaceSearchType.BY_VALUE)) onSearch({ minPrice: -1, maxPrice: -1, query: '', type: sortType });
}, [ onSearch, searchType ]);
const onClickSearch = useCallback(() =>
{
const minPrice = ((min > 0) ? min : -1);
const maxPrice = ((max > 0) ? max : -1);
onSearch({ minPrice: minPrice, maxPrice: maxPrice, type: sortType, query: searchQuery });
}, [ max, min, onSearch, searchQuery, sortType ]);
useEffect(() =>
{
if(!sortTypes || !sortTypes.length) return;
const sortType = sortTypes[0];
setSortType(sortType);
if(searchType === MarketplaceSearchType.BY_ACTIVITY || MarketplaceSearchType.BY_VALUE === searchType) onSearch({ minPrice: -1, maxPrice: -1, query: '', type: sortType });
}, [ onSearch, searchType, sortTypes ]);
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Text className="col-span-3">{ LocalizeText('catalog.marketplace.sort_order') }</Text>
<select className="form-select form-select-sm" value={ sortType } onChange={ event => onSortTypeChange(parseInt(event.target.value)) }>
{ sortTypes.map(type => <option key={ type } value={ type }>{ LocalizeText(`catalog.marketplace.sort.${ type }`) }</option>) }
</select>
</div>
{ searchType === MarketplaceSearchType.ADVANCED &&
<>
<div className="flex items-center gap-1">
<Text className="col-span-3">{ LocalizeText('catalog.marketplace.search_name') }</Text>
<NitroInput
value={ searchQuery }
onChange={ event => setSearchQuery(event.target.value) } />
</div>
<div className="flex items-center gap-1">
<Text className="col-span-3">{ LocalizeText('catalog.marketplace.search_price') }</Text>
<div className="flex w-full gap-1">
<NitroInput
min={ 0 }
type="number"
value={ min }
onChange={ event => setMin(event.target.valueAsNumber) } />
<NitroInput
min={ 0 }
type="number"
value={ max }
onChange={ event => setMax(event.target.valueAsNumber) } />
</div>
</div>
<Button className="mx-auto" variant="secondary" onClick={ onClickSearch }>{ LocalizeText('generic.search') }</Button>
</> }
</div>
);
};
@@ -0,0 +1,124 @@
import { GetMarketplaceConfigurationMessageComposer, MakeOfferMessageComposer, MarketplaceConfigurationEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FurnitureItem, LocalizeText, ProductTypeEnum, SendMessageComposer } from '../../../../../../api';
import { Button, Column, Grid, LayoutFurniImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../../../common';
import { CatalogPostMarketplaceOfferEvent } from '../../../../../../events';
import { useCatalog, useMessageEvent, useNotification, useUiEvent } from '../../../../../../hooks';
import { NitroInput } from '../../../../../../layout';
export const MarketplacePostOfferView: FC<{}> = props =>
{
const [ item, setItem ] = useState<FurnitureItem>(null);
const [ askingPrice, setAskingPrice ] = useState(0);
const [ tempAskingPrice, setTempAskingPrice ] = useState('0');
const { catalogOptions = null, setCatalogOptions = null } = useCatalog();
const { marketplaceConfiguration = null } = catalogOptions;
const { showConfirm = null } = useNotification();
const updateAskingPrice = (price: string) =>
{
setTempAskingPrice(price);
const newValue = parseInt(price);
if(isNaN(newValue) || (newValue === askingPrice)) return;
setAskingPrice(parseInt(price));
};
useMessageEvent<MarketplaceConfigurationEvent>(MarketplaceConfigurationEvent, event =>
{
const parser = event.getParser();
setCatalogOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.marketplaceConfiguration = parser;
return newValue;
});
});
useUiEvent<CatalogPostMarketplaceOfferEvent>(CatalogPostMarketplaceOfferEvent.POST_MARKETPLACE, event => setItem(event.item));
useEffect(() =>
{
if(!item || marketplaceConfiguration) return;
SendMessageComposer(new GetMarketplaceConfigurationMessageComposer());
}, [ item, marketplaceConfiguration ]);
useEffect(() =>
{
if(!item) return;
return () => setAskingPrice(0);
}, [ item ]);
if(!marketplaceConfiguration || !item) return null;
const getFurniTitle = (item ? LocalizeText(item.isWallItem ? 'wallItem.name.' + item.type : 'roomItem.name.' + item.type) : '');
const getFurniDescription = (item ? LocalizeText(item.isWallItem ? 'wallItem.desc.' + item.type : 'roomItem.desc.' + item.type) : '');
const getCommission = () => Math.max(Math.ceil(((marketplaceConfiguration.commission * 0.01) * askingPrice)), 1);
const postItem = () =>
{
if(!item || (askingPrice < marketplaceConfiguration.minimumPrice)) return;
showConfirm(LocalizeText('inventory.marketplace.confirm_offer.info', [ 'furniname', 'price' ], [ getFurniTitle, askingPrice.toString() ]), () =>
{
SendMessageComposer(new MakeOfferMessageComposer(askingPrice, item.isWallItem ? 2 : 1, item.id));
setItem(null);
},
() =>
{
setItem(null);
}, null, null, LocalizeText('inventory.marketplace.confirm_offer.title'));
};
return (
<NitroCardView className="nitro-catalog-layout-marketplace-post-offer" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText('inventory.marketplace.make_offer.title') } onCloseClick={ event => setItem(null) } />
<NitroCardContentView overflow="hidden">
<Grid fullHeight>
<Column center className="bg-muted rounded p-2" overflow="hidden" size={ 4 }>
<LayoutFurniImageView extraData={ item.extra.toString() } productClassId={ item.type } productType={ item.isWallItem ? ProductTypeEnum.WALL : ProductTypeEnum.FLOOR } />
</Column>
<Column justifyContent="between" overflow="hidden" size={ 8 }>
<Column grow gap={ 1 }>
<Text fontWeight="bold">{ getFurniTitle }</Text>
<Text shrink truncate>{ getFurniDescription }</Text>
</Column>
<Column overflow="auto">
<Text italics>
{ LocalizeText('inventory.marketplace.make_offer.expiration_info', [ 'time' ], [ marketplaceConfiguration.offerTime.toString() ]) }
</Text>
<div className="input-group has-validation">
<NitroInput min={ 0 } placeholder={ LocalizeText('inventory.marketplace.make_offer.price_request') } type="number" value={ tempAskingPrice } onChange={ event => updateAskingPrice(event.target.value) } />
{ ((askingPrice < marketplaceConfiguration.minimumPrice) || isNaN(askingPrice)) &&
<div className="invalid-feedback d-block">
{ LocalizeText('inventory.marketplace.make_offer.min_price', [ 'minprice' ], [ marketplaceConfiguration.minimumPrice.toString() ]) }
</div> }
{ ((askingPrice > marketplaceConfiguration.maximumPrice) && !isNaN(askingPrice)) &&
<div className="invalid-feedback d-block">
{ LocalizeText('inventory.marketplace.make_offer.max_price', [ 'maxprice' ], [ marketplaceConfiguration.maximumPrice.toString() ]) }
</div> }
{ (!((askingPrice < marketplaceConfiguration.minimumPrice) || (askingPrice > marketplaceConfiguration.maximumPrice) || isNaN(askingPrice))) &&
<div className="invalid-feedback d-block">
{ LocalizeText('inventory.marketplace.make_offer.final_price', [ 'commission', 'finalprice' ], [ getCommission().toString(), (askingPrice + getCommission()).toString() ]) }
</div> }
</div>
<Button disabled={ ((askingPrice < marketplaceConfiguration.minimumPrice) || (askingPrice > marketplaceConfiguration.maximumPrice) || isNaN(askingPrice)) } onClick={ postItem }>
{ LocalizeText('inventory.marketplace.make_offer.post') }
</Button>
</Column>
</Column>
</Grid>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,243 @@
import { ApproveNameMessageComposer, ApproveNameMessageEvent, ColorConverter, GetSellablePetPalettesComposer, PurchaseFromCatalogComposer, SellablePetPaletteData } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FaFillDrip } from 'react-icons/fa';
import { DispatchUiEvent, GetPetAvailableColors, GetPetIndexFromLocalization, LocalizeText, SendMessageComposer } from '../../../../../../api';
import { AutoGrid, Button, Column, Grid, LayoutGridItem, LayoutPetImageView, Text } from '../../../../../../common';
import { CatalogPurchaseFailureEvent } from '../../../../../../events';
import { useCatalog, useMessageEvent } from '../../../../../../hooks';
import { CatalogAddOnBadgeWidgetView } from '../../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogPurchaseWidgetView } from '../../widgets/CatalogPurchaseWidgetView';
import { CatalogTotalPriceWidget } from '../../widgets/CatalogTotalPriceWidget';
import { CatalogViewProductWidgetView } from '../../widgets/CatalogViewProductWidgetView';
import { CatalogLayoutProps } from '../CatalogLayout.types';
export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ petIndex, setPetIndex ] = useState(-1);
const [ sellablePalettes, setSellablePalettes ] = useState<SellablePetPaletteData[]>([]);
const [ selectedPaletteIndex, setSelectedPaletteIndex ] = useState(-1);
const [ sellableColors, setSellableColors ] = useState<number[][]>([]);
const [ selectedColorIndex, setSelectedColorIndex ] = useState(-1);
const [ colorsShowing, setColorsShowing ] = useState(false);
const [ petName, setPetName ] = useState('');
const [ approvalPending, setApprovalPending ] = useState(true);
const [ approvalResult, setApprovalResult ] = useState(-1);
const { currentOffer = null, setCurrentOffer = null, setPurchaseOptions = null, catalogOptions = null, roomPreviewer = null } = useCatalog();
const { petPalettes = null } = catalogOptions;
const getColor = useMemo(() =>
{
if(!sellableColors.length || (selectedColorIndex === -1)) return 0xFFFFFF;
return sellableColors[selectedColorIndex][0];
}, [ sellableColors, selectedColorIndex ]);
const petBreedName = useMemo(() =>
{
if((petIndex === -1) || !sellablePalettes.length || (selectedPaletteIndex === -1)) return '';
return LocalizeText(`pet.breed.${ petIndex }.${ sellablePalettes[selectedPaletteIndex].breedId }`);
}, [ petIndex, sellablePalettes, selectedPaletteIndex ]);
const petPurchaseString = useMemo(() =>
{
if(!sellablePalettes.length || (selectedPaletteIndex === -1)) return '';
const paletteId = sellablePalettes[selectedPaletteIndex].paletteId;
let color = 0xFFFFFF;
if(petIndex <= 7)
{
if(selectedColorIndex === -1) return '';
color = sellableColors[selectedColorIndex][0];
}
let colorString = color.toString(16).toUpperCase();
while(colorString.length < 6) colorString = ('0' + colorString);
return `${ paletteId }\n${ colorString }`;
}, [ sellablePalettes, selectedPaletteIndex, petIndex, sellableColors, selectedColorIndex ]);
const validationErrorMessage = useMemo(() =>
{
let key: string = '';
switch(approvalResult)
{
case 1:
key = 'catalog.alert.petname.long';
break;
case 2:
key = 'catalog.alert.petname.short';
break;
case 3:
key = 'catalog.alert.petname.chars';
break;
case 4:
key = 'catalog.alert.petname.bobba';
break;
}
if(!key || !key.length) return '';
return LocalizeText(key);
}, [ approvalResult ]);
const purchasePet = useCallback(() =>
{
if(approvalResult === -1)
{
SendMessageComposer(new ApproveNameMessageComposer(petName, 1));
return;
}
if(approvalResult === 0)
{
SendMessageComposer(new PurchaseFromCatalogComposer(page.pageId, currentOffer.offerId, `${ petName }\n${ petPurchaseString }`, 1));
return;
}
}, [ page, currentOffer, petName, petPurchaseString, approvalResult ]);
useMessageEvent<ApproveNameMessageEvent>(ApproveNameMessageEvent, event =>
{
const parser = event.getParser();
setApprovalResult(parser.result);
if(parser.result === 0) purchasePet();
else DispatchUiEvent(new CatalogPurchaseFailureEvent(-1));
});
useEffect(() =>
{
if(!page || !page.offers.length) return;
const offer = page.offers[0];
setCurrentOffer(offer);
setPetIndex(GetPetIndexFromLocalization(offer.localizationId));
setColorsShowing(false);
}, [ page, setCurrentOffer ]);
useEffect(() =>
{
if(!currentOffer) return;
const productData = currentOffer.product.productData;
if(!productData) return;
if(petPalettes)
{
for(const paletteData of petPalettes)
{
if(paletteData.breed !== productData.type) continue;
const palettes: SellablePetPaletteData[] = [];
for(const palette of paletteData.palettes)
{
if(!palette.sellable) continue;
palettes.push(palette);
}
setSelectedPaletteIndex((palettes.length ? 0 : -1));
setSellablePalettes(palettes);
return;
}
}
setSelectedPaletteIndex(-1);
setSellablePalettes([]);
SendMessageComposer(new GetSellablePetPalettesComposer(productData.type));
}, [ currentOffer, petPalettes ]);
useEffect(() =>
{
if(petIndex === -1) return;
const colors = GetPetAvailableColors(petIndex, sellablePalettes);
setSelectedColorIndex((colors.length ? 0 : -1));
setSellableColors(colors);
}, [ petIndex, sellablePalettes ]);
useEffect(() =>
{
if(!roomPreviewer) return;
roomPreviewer.reset(false);
if((petIndex === -1) || !sellablePalettes.length || (selectedPaletteIndex === -1)) return;
let petFigureString = `${ petIndex } ${ sellablePalettes[selectedPaletteIndex].paletteId }`;
if(petIndex <= 7) petFigureString += ` ${ getColor.toString(16) }`;
roomPreviewer.addPetIntoRoom(petFigureString);
}, [ roomPreviewer, petIndex, sellablePalettes, selectedPaletteIndex, getColor ]);
useEffect(() =>
{
setApprovalResult(-1);
}, [ petName ]);
if(!currentOffer) return null;
return (
<Grid>
<Column overflow="hidden" size={ 7 }>
<AutoGrid columnCount={ 5 }>
{ !colorsShowing && (sellablePalettes.length > 0) && sellablePalettes.map((palette, index) =>
{
return (
<LayoutGridItem key={ index } itemActive={ (selectedPaletteIndex === index) } onClick={ event => setSelectedPaletteIndex(index) }>
<LayoutPetImageView direction={ 2 } headOnly={ true } paletteId={ palette.paletteId } typeId={ petIndex } />
</LayoutGridItem>
);
}) }
{ colorsShowing && (sellableColors.length > 0) && sellableColors.map((colorSet, index) => <LayoutGridItem key={ index } itemHighlight className="clear-bg" itemActive={ (selectedColorIndex === index) } itemColor={ ColorConverter.int2rgb(colorSet[0]) } onClick={ event => setSelectedColorIndex(index) } />) }
</AutoGrid>
</Column>
<Column center={ !currentOffer } overflow="hidden" size={ 5 }>
{ !currentOffer &&
<>
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: page.localization.getText(0) } } />
</> }
{ currentOffer &&
<>
<div className="relative overflow-hidden">
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 end-1" position="absolute" />
{ ((petIndex > -1) && (petIndex <= 7)) &&
<Button className="bottom-1 start-1" position="absolute" onClick={ event => setColorsShowing(!colorsShowing) }>
<FaFillDrip className="fa-icon" />
</Button> }
</div>
<Column grow gap={ 1 }>
<Text truncate>{ petBreedName }</Text>
<Column grow gap={ 1 }>
<input className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem] form-control-sm w-full" placeholder={ LocalizeText('widgets.petpackage.name.title') } type="text" value={ petName } onChange={ event => setPetName(event.target.value) } />
{ (approvalResult > 0) &&
<div className="invalid-feedback d-block m-0">{ validationErrorMessage }</div> }
</Column>
<div className="flex justify-end">
<CatalogTotalPriceWidget alignItems="end" justifyContent="end" />
</div>
<CatalogPurchaseWidgetView purchaseCallback={ purchasePet } />
</Column>
</> }
</Column>
</Grid>
);
};
@@ -0,0 +1,62 @@
import { SelectClubGiftComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../../api';
import { AutoGrid, Text } from '../../../../../../common';
import { useCatalog, useNotification, usePurse } from '../../../../../../hooks';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { VipGiftItem } from './VipGiftItemView';
export const CatalogLayoutVipGiftsView: FC<CatalogLayoutProps> = props =>
{
const { purse = null } = usePurse();
const { catalogOptions = null, setCatalogOptions = null } = useCatalog();
const { clubGifts = null } = catalogOptions;
const { showConfirm = null } = useNotification();
const giftsAvailable = useCallback(() =>
{
if(!clubGifts) return '';
if(clubGifts.giftsAvailable > 0) return LocalizeText('catalog.club_gift.available', [ 'amount' ], [ clubGifts.giftsAvailable.toString() ]);
if(clubGifts.daysUntilNextGift > 0) return LocalizeText('catalog.club_gift.days_until_next', [ 'days' ], [ clubGifts.daysUntilNextGift.toString() ]);
if(purse.isVip) return LocalizeText('catalog.club_gift.not_available');
return LocalizeText('catalog.club_gift.no_club');
}, [ clubGifts, purse ]);
const selectGift = useCallback((localizationId: string) =>
{
showConfirm(LocalizeText('catalog.club_gift.confirm'), () =>
{
SendMessageComposer(new SelectClubGiftComposer(localizationId));
setCatalogOptions(prevValue =>
{
prevValue.clubGifts.giftsAvailable--;
return { ...prevValue };
});
}, null);
}, [ setCatalogOptions, showConfirm ]);
const sortGifts = useMemo(() =>
{
let gifts = clubGifts.offers.sort((a,b) =>
{
return clubGifts.getOfferExtraData(a.offerId).daysRequired - clubGifts.getOfferExtraData(b.offerId).daysRequired;
});
return gifts;
},[ clubGifts ]);
return (
<>
<Text shrink truncate fontWeight="bold">{ giftsAvailable() }</Text>
<AutoGrid className="nitro-catalog-layout-vip-gifts-grid" columnCount={ 1 }>
{ (clubGifts.offers.length > 0) && sortGifts.map(offer => <VipGiftItem key={ offer.offerId } daysRequired={ clubGifts.getOfferExtraData(offer.offerId).daysRequired } isAvailable={ (clubGifts.getOfferExtraData(offer.offerId).isSelectable && (clubGifts.giftsAvailable > 0)) } offer={ offer } onSelect={ selectGift }/>) }
</AutoGrid>
</>
);
};
@@ -0,0 +1,63 @@
import { CatalogPageMessageOfferData } from '@nitrots/nitro-renderer';
import { FC, useCallback } from 'react';
import { LocalizeText, ProductImageUtility } from '../../../../../../api';
import { Button, LayoutGridItem, LayoutImage, Text } from '../../../../../../common';
export interface VipGiftItemViewProps
{
offer: CatalogPageMessageOfferData;
isAvailable: boolean;
daysRequired: number;
onSelect(localizationId: string): void;
}
export const VipGiftItem : FC<VipGiftItemViewProps> = props =>
{
const { offer = null, isAvailable = false, daysRequired = 0, onSelect = null } = props;
const getImageUrlForOffer = useCallback( () =>
{
if(!offer || !offer.products.length) return '';
const productData = offer.products[0];
return ProductImageUtility.getProductImageUrl(productData.productType, productData.furniClassId, productData.extraParam);
}, [ offer ]);
const getItemTitle = useCallback(() =>
{
if(!offer || !offer.products.length) return '';
const productData = offer.products[0];
const localizationKey = ProductImageUtility.getProductCategory(productData.productType, productData.furniClassId) === 2 ? 'wallItem.name.' + productData.furniClassId : 'roomItem.name.' + productData.furniClassId;
return LocalizeText(localizationKey);
}, [ offer ]);
const getItemDesc = useCallback( () =>
{
if(!offer || !offer.products.length) return '';
const productData = offer.products[0];
const localizationKey = ProductImageUtility.getProductCategory(productData.productType, productData.furniClassId) === 2 ? 'wallItem.desc.' + productData.furniClassId : 'roomItem.desc.' + productData.furniClassId ;
return LocalizeText(localizationKey);
}, [ offer ]);
const getMonthsRequired = useCallback(() =>
{
return Math.floor(daysRequired / 31);
},[ daysRequired ]);
return (
<LayoutGridItem alignItems="center" center={ false } className="p-1" column={ false }>
<LayoutImage imageUrl={ getImageUrlForOffer() } />
<Text grow fontWeight="bold">{ getItemTitle() }</Text>
<Button disabled={ !isAvailable } variant="secondary" onClick={ () => onSelect(offer.localizationId) }>
{ LocalizeText('catalog.club_gift.select') }
</Button>
</LayoutGridItem>
);
};
@@ -0,0 +1,18 @@
import { FC } from 'react';
import { BaseProps, LayoutBadgeImageView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
interface CatalogAddOnBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
{
}
export const CatalogAddOnBadgeWidgetView: FC<CatalogAddOnBadgeWidgetViewProps> = props =>
{
const { ...rest } = props;
const { currentOffer = null } = useCatalog();
if(!currentOffer || !currentOffer.badgeCode || !currentOffer.badgeCode.length) return null;
return <LayoutBadgeImageView badgeCode={ currentOffer.badgeCode } { ...rest } />;
};
@@ -0,0 +1,76 @@
import { StringDataType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { AutoGrid, AutoGridProps, LayoutBadgeImageView, LayoutGridItem } from '../../../../../common';
import { useCatalog, useInventoryBadges } from '../../../../../hooks';
const EXCLUDED_BADGE_CODES: string[] = [];
interface CatalogBadgeSelectorWidgetViewProps extends AutoGridProps
{
}
export const CatalogBadgeSelectorWidgetView: FC<CatalogBadgeSelectorWidgetViewProps> = props =>
{
const { columnCount = 5, ...rest } = props;
const [ isVisible, setIsVisible ] = useState(false);
const [ currentBadgeCode, setCurrentBadgeCode ] = useState<string>(null);
const { currentOffer = null, setPurchaseOptions = null } = useCatalog();
const { badgeCodes = [], activate = null, deactivate = null } = useInventoryBadges();
const previewStuffData = useMemo(() =>
{
if(!currentBadgeCode) return null;
const stuffData = new StringDataType();
stuffData.setValue([ '0', currentBadgeCode, '', '' ]);
return stuffData;
}, [ currentBadgeCode ]);
useEffect(() =>
{
if(!currentOffer) return;
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.extraParamRequired = true;
newValue.extraData = ((previewStuffData && previewStuffData.getValue(1)) || null);
newValue.previewStuffData = previewStuffData;
return newValue;
});
}, [ currentOffer, previewStuffData, setPurchaseOptions ]);
useEffect(() =>
{
if(!isVisible) return;
const id = activate();
return () => deactivate(id);
}, [ isVisible, activate, deactivate ]);
useEffect(() =>
{
setIsVisible(true);
return () => setIsVisible(false);
}, []);
return (
<AutoGrid columnCount={ columnCount } { ...rest }>
{ badgeCodes && (badgeCodes.length > 0) && badgeCodes.map((badgeCode, index) =>
{
return (
<LayoutGridItem key={ index } itemActive={ (currentBadgeCode === badgeCode) } onClick={ event => setCurrentBadgeCode(badgeCode) }>
<LayoutBadgeImageView badgeCode={ badgeCode } />
</LayoutGridItem>
);
}) }
</AutoGrid>
);
};
@@ -0,0 +1,29 @@
import { FC, useEffect, useRef } from 'react';
import { AutoGrid, AutoGridProps, LayoutGridItem } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
interface CatalogBundleGridWidgetViewProps extends AutoGridProps
{
}
export const CatalogBundleGridWidgetView: FC<CatalogBundleGridWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const { currentOffer = null } = useCatalog();
const elementRef = useRef<HTMLDivElement>();
useEffect(() =>
{
if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
}, [ currentOffer ]);
if(!currentOffer) return null;
return (
<AutoGrid columnCount={ 5 } innerRef={ elementRef } { ...rest }>
{ currentOffer.products && (currentOffer.products.length > 0) && currentOffer.products.map((product, index) => <LayoutGridItem key={ index } itemCount={ product.productCount } itemImage={ product.getIconUrl() } />) }
{ children }
</AutoGrid>
);
};
@@ -0,0 +1,16 @@
import { FC, useEffect } from 'react';
import { useCatalog } from '../../../../../hooks';
export const CatalogFirstProductSelectorWidgetView: FC<{}> = props =>
{
const { currentPage = null, setCurrentOffer = null } = useCatalog();
useEffect(() =>
{
if(!currentPage || !currentPage.offers.length) return;
setCurrentOffer(currentPage.offers[0]);
}, [ currentPage, setCurrentOffer ]);
return null;
};
@@ -0,0 +1,31 @@
import { StringDataType } from '@nitrots/nitro-renderer';
import { FC, useMemo } from 'react';
import { BaseProps, LayoutBadgeImageView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
interface CatalogGuildBadgeWidgetViewProps extends BaseProps<HTMLDivElement>
{
}
export const CatalogGuildBadgeWidgetView: FC<CatalogGuildBadgeWidgetViewProps> = props =>
{
const { ...rest } = props;
const { currentOffer = null, purchaseOptions = null } = useCatalog();
const { previewStuffData = null } = purchaseOptions;
const badgeCode = useMemo(() =>
{
if(!currentOffer || !previewStuffData) return null;
const badgeCode = (previewStuffData as StringDataType).getValue(2);
if(!badgeCode || !badgeCode.length) return null;
return badgeCode;
}, [ currentOffer, previewStuffData ]);
if(!badgeCode) return null;
return <LayoutBadgeImageView badgeCode={ badgeCode } isGroup={ true } { ...rest } />;
};
@@ -0,0 +1,75 @@
import { CatalogGroupsComposer, StringDataType } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../api';
import { Button, Flex } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
export const CatalogGuildSelectorWidgetView: FC<{}> = props =>
{
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState<number>(0);
const { currentOffer = null, catalogOptions = null, setPurchaseOptions = null } = useCatalog();
const { groups = null } = catalogOptions;
const previewStuffData = useMemo(() =>
{
if(!groups || !groups.length) return null;
const group = groups[selectedGroupIndex];
if(!group) return null;
const stuffData = new StringDataType();
stuffData.setValue([ '0', group.groupId.toString(), group.badgeCode, group.colorA, group.colorB ]);
return stuffData;
}, [ selectedGroupIndex, groups ]);
useEffect(() =>
{
if(!currentOffer) return;
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.extraParamRequired = true;
newValue.extraData = ((previewStuffData && previewStuffData.getValue(1)) || null);
newValue.previewStuffData = previewStuffData;
return newValue;
});
}, [ currentOffer, previewStuffData, setPurchaseOptions ]);
useEffect(() =>
{
SendMessageComposer(new CatalogGroupsComposer());
}, []);
if(!groups || !groups.length)
{
return (
<div className="bg-muted rounded p-1 text-black text-center">
{ LocalizeText('catalog.guild_selector.members_only') }
<Button className="mt-1">
{ LocalizeText('catalog.guild_selector.find_groups') }
</Button>
</div>
);
}
const selectedGroup = groups[selectedGroupIndex];
return (
<div className="flex gap-1">
{ !!selectedGroup &&
<Flex className="rounded border" overflow="hidden">
<div className="h-full" style={ { width: '20px', backgroundColor: '#' + selectedGroup.colorA } } />
<div className="h-full" style={ { width: '20px', backgroundColor: '#' + selectedGroup.colorB } } />
</Flex> }
<select className="form-select form-select-sm" value={ selectedGroupIndex } onChange={ event => setSelectedGroupIndex(parseInt(event.target.value)) }>
{ groups.map((group, index) => <option key={ index } value={ index }>{ group.groupName }</option>) }
</select>
</div>
);
};
@@ -0,0 +1,52 @@
import { FC, useEffect, useRef } from 'react';
import { IPurchasableOffer, ProductTypeEnum } from '../../../../../api';
import { AutoGrid, AutoGridProps } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
interface CatalogItemGridWidgetViewProps extends AutoGridProps
{
}
export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const { currentOffer = null, setCurrentOffer = null, currentPage = null, setPurchaseOptions = null } = useCatalog();
const elementRef = useRef<HTMLDivElement>();
useEffect(() =>
{
if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
}, [ currentPage ]);
if(!currentPage) return null;
const selectOffer = (offer: IPurchasableOffer) =>
{
offer.activate();
if(offer.isLazy) return;
setCurrentOffer(offer);
if(offer.product && (offer.product.productType === ProductTypeEnum.WALL))
{
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.extraData = (offer.product.extraParam || null);
return newValue;
});
}
};
return (
<AutoGrid columnCount={ columnCount } innerRef={ elementRef } { ...rest }>
{ currentPage.offers && (currentPage.offers.length > 0) && currentPage.offers.map((offer, index) => <CatalogGridOfferView key={ index } itemActive={ (currentOffer && (currentOffer.offerId === offer.offerId)) } offer={ offer } selectOffer={ selectOffer } />) }
{ children }
</AutoGrid>
);
};
@@ -0,0 +1,17 @@
import { FC } from 'react';
import { Offer } from '../../../../../api';
import { LayoutLimitedEditionCompletePlateView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
export const CatalogLimitedItemWidgetView: FC = props =>
{
const { currentOffer = null } = useCatalog();
if(!currentOffer || (currentOffer.pricingModel !== Offer.PRICING_MODEL_SINGLE) || !currentOffer.product.isUniqueLimitedItem) return null;
return (
<div className="w-full">
<LayoutLimitedEditionCompletePlateView className="mx-auto" uniqueLimitedItemsLeft={ currentOffer.product.uniqueLimitedItemsLeft } uniqueLimitedSeriesSize={ currentOffer.product.uniqueLimitedItemSeriesSize } />
</div>
);
};
@@ -0,0 +1,37 @@
import { FC } from 'react';
import { FaPlus } from 'react-icons/fa';
import { IPurchasableOffer } from '../../../../../api';
import { LayoutCurrencyIcon, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
interface CatalogPriceDisplayWidgetViewProps
{
offer: IPurchasableOffer;
separator?: boolean;
}
export const CatalogPriceDisplayWidgetView: FC<CatalogPriceDisplayWidgetViewProps> = props =>
{
const { offer = null, separator = false } = props;
const { purchaseOptions = null } = useCatalog();
const { quantity = 1 } = purchaseOptions;
if(!offer) return null;
return (
<>
{ (offer.priceInCredits > 0) &&
<div className="flex items-center gap-1">
<Text bold>{ (offer.priceInCredits * quantity) }</Text>
<LayoutCurrencyIcon type={ -1 } />
</div> }
{ separator && (offer.priceInCredits > 0) && (offer.priceInActivityPoints > 0) &&
<FaPlus className="fa-icon" color="black" size="xs" /> }
{ (offer.priceInActivityPoints > 0) &&
<div className="flex items-center gap-1">
<Text bold>{ (offer.priceInActivityPoints * quantity) }</Text>
<LayoutCurrencyIcon type={ offer.activityPointType } />
</div> }
</>
);
};
@@ -0,0 +1,164 @@
import { CreateLinkEvent, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { CatalogPurchaseState, DispatchUiEvent, GetClubMemberLevel, LocalStorageKeys, LocalizeText, Offer, SendMessageComposer } from '../../../../../api';
import { Button, LayoutLoadingSpinnerView } from '../../../../../common';
import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchaseFailureEvent, CatalogPurchaseNotAllowedEvent, CatalogPurchaseSoldOutEvent, CatalogPurchasedEvent } from '../../../../../events';
import { useCatalog, useLocalStorage, usePurse, useUiEvent } from '../../../../../hooks';
interface CatalogPurchaseWidgetViewProps
{
noGiftOption?: boolean;
purchaseCallback?: () => void;
}
export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = props =>
{
const { noGiftOption = false, purchaseCallback = null } = props;
const [ purchaseWillBeGift, setPurchaseWillBeGift ] = useState(false);
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useLocalStorage(LocalStorageKeys.CATALOG_SKIP_PURCHASE_CONFIRMATION, false);
const { currentOffer = null, currentPage = null, purchaseOptions = null, setPurchaseOptions = null } = useCatalog();
const { getCurrencyAmount = null } = usePurse();
const onCatalogEvent = useCallback((event: CatalogEvent) =>
{
switch(event.type)
{
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
setPurchaseState(CatalogPurchaseState.NONE);
return;
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
setPurchaseState(CatalogPurchaseState.FAILED);
return;
case CatalogPurchaseNotAllowedEvent.NOT_ALLOWED:
setPurchaseState(CatalogPurchaseState.FAILED);
return;
case CatalogPurchaseSoldOutEvent.SOLD_OUT:
setPurchaseState(CatalogPurchaseState.SOLD_OUT);
return;
}
}, []);
useUiEvent(CatalogPurchasedEvent.PURCHASE_SUCCESS, onCatalogEvent);
useUiEvent(CatalogPurchaseFailureEvent.PURCHASE_FAILED, onCatalogEvent);
useUiEvent(CatalogPurchaseNotAllowedEvent.NOT_ALLOWED, onCatalogEvent);
useUiEvent(CatalogPurchaseSoldOutEvent.SOLD_OUT, onCatalogEvent);
const isLimitedSoldOut = useMemo(() =>
{
if(!currentOffer) return false;
if(purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length)) return false;
if(currentOffer.pricingModel === Offer.PRICING_MODEL_SINGLE)
{
const product = currentOffer.product;
if(product && product.isUniqueLimitedItem) return !product.uniqueLimitedItemsLeft;
}
return false;
}, [ currentOffer, purchaseOptions ]);
const purchase = (isGift: boolean = false) =>
{
if(!currentOffer) return;
if(GetClubMemberLevel() < currentOffer.clubLevel)
{
CreateLinkEvent('habboUI/open/hccenter');
return;
}
if(isGift)
{
DispatchUiEvent(new CatalogInitGiftEvent(currentOffer.page.pageId, currentOffer.offerId, purchaseOptions.extraData));
return;
}
setPurchaseState(CatalogPurchaseState.PURCHASE);
if(purchaseCallback)
{
purchaseCallback();
return;
}
let pageId = currentOffer.page.pageId;
// if(pageId === -1)
// {
// const nodes = getNodesByOfferId(currentOffer.offerId);
// if(nodes) pageId = nodes[0].pageId;
// }
SendMessageComposer(new PurchaseFromCatalogComposer(pageId, currentOffer.offerId, purchaseOptions.extraData, purchaseOptions.quantity));
};
useEffect(() =>
{
if(!currentOffer) return;
setPurchaseState(CatalogPurchaseState.NONE);
}, [ currentOffer, setPurchaseOptions ]);
useEffect(() =>
{
let timeout: ReturnType<typeof setTimeout> = null;
if((purchaseState === CatalogPurchaseState.CONFIRM) || (purchaseState === CatalogPurchaseState.FAILED))
{
timeout = setTimeout(() => setPurchaseState(CatalogPurchaseState.NONE), 3000);
}
return () =>
{
if(timeout) clearTimeout(timeout);
};
}, [ purchaseState ]);
if(!currentOffer) return null;
const PurchaseButton = () =>
{
const priceCredits = (currentOffer.priceInCredits * purchaseOptions.quantity);
const pricePoints = (currentOffer.priceInActivityPoints * purchaseOptions.quantity);
if(GetClubMemberLevel() < currentOffer.clubLevel) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.hc.required') }</Button>;
if(isLimitedSoldOut) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
if(priceCredits > getCurrencyAmount(-1)) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.notenough.title') }</Button>;
if(pricePoints > getCurrencyAmount(currentOffer.activityPointType)) return <Button disabled variant="danger">{ LocalizeText('catalog.alert.notenough.activitypoints.title.' + currentOffer.activityPointType) }</Button>;
switch(purchaseState)
{
case CatalogPurchaseState.CONFIRM:
return <Button variant="warning" onClick={ event => purchase() }>{ LocalizeText('catalog.marketplace.confirm_title') }</Button>;
case CatalogPurchaseState.PURCHASE:
return <Button disabled><LayoutLoadingSpinnerView /></Button>;
case CatalogPurchaseState.FAILED:
return <Button variant="danger">{ LocalizeText('generic.failed') }</Button>;
case CatalogPurchaseState.SOLD_OUT:
return <Button variant="danger">{ LocalizeText('generic.failed') + ' - ' + LocalizeText('catalog.alert.limited_edition_sold_out.title') }</Button>;
case CatalogPurchaseState.NONE:
default:
return <Button disabled={ (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length)) } onClick={ event => setPurchaseState(CatalogPurchaseState.CONFIRM) }>{ LocalizeText('catalog.purchase_confirmation.' + (currentOffer.isRentOffer ? 'rent' : 'buy')) }</Button>;
}
};
return (
<>
<PurchaseButton />
{ (!noGiftOption && !currentOffer.isRentOffer) &&
<Button disabled={ ((purchaseOptions.quantity > 1) || !currentOffer.giftable || isLimitedSoldOut || (purchaseOptions.extraParamRequired && (!purchaseOptions.extraData || !purchaseOptions.extraData.length))) } onClick={ event => purchase(true) }>
{ LocalizeText('catalog.purchase_confirmation.gift') }
</Button> }
</>
);
};
@@ -0,0 +1,14 @@
import { FC } from 'react';
import { useCatalog } from '../../../../../hooks';
import { CatalogPriceDisplayWidgetView } from './CatalogPriceDisplayWidgetView';
export const CatalogSimplePriceWidgetView: FC<{}> = props =>
{
const { currentOffer = null } = useCatalog();
return (
<div className="flex items-center bg-muted p-1 rounded gap-1">
<CatalogPriceDisplayWidgetView offer={ currentOffer } separator={ true } />
</div>
);
};
@@ -0,0 +1,7 @@
import { FC } from 'react';
import { CatalogFirstProductSelectorWidgetView } from './CatalogFirstProductSelectorWidgetView';
export const CatalogSingleViewWidgetView: FC<{}> = props =>
{
return <CatalogFirstProductSelectorWidgetView />;
};
@@ -0,0 +1,115 @@
import { FC, useEffect, useRef, useState } from 'react';
import { IPurchasableOffer, LocalizeText, Offer, ProductTypeEnum } from '../../../../../api';
import { AutoGrid, AutoGridProps, Button } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
interface CatalogSpacesWidgetViewProps extends AutoGridProps
{
}
const SPACES_GROUP_NAMES = [ 'floors', 'walls', 'views' ];
export const CatalogSpacesWidgetView: FC<CatalogSpacesWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const [ groupedOffers, setGroupedOffers ] = useState<IPurchasableOffer[][]>(null);
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState(-1);
const [ selectedOfferForGroup, setSelectedOfferForGroup ] = useState<IPurchasableOffer[]>(null);
const { currentPage = null, currentOffer = null, setCurrentOffer = null, setPurchaseOptions = null } = useCatalog();
const elementRef = useRef<HTMLDivElement>();
const setSelectedOffer = (offer: IPurchasableOffer) =>
{
if(!offer) return;
setSelectedOfferForGroup(prevValue =>
{
const newValue = [ ...prevValue ];
newValue[selectedGroupIndex] = offer;
return newValue;
});
};
useEffect(() =>
{
if(!currentPage) return;
const groupedOffers: IPurchasableOffer[][] = [ [], [], [] ];
for(const offer of currentPage.offers)
{
if((offer.pricingModel !== Offer.PRICING_MODEL_SINGLE) && (offer.pricingModel !== Offer.PRICING_MODEL_MULTI)) continue;
const product = offer.product;
if(!product || ((product.productType !== ProductTypeEnum.WALL) && (product.productType !== ProductTypeEnum.FLOOR)) || !product.furnitureData) continue;
const className = product.furnitureData.className;
switch(className)
{
case 'floor':
groupedOffers[0].push(offer);
break;
case 'wallpaper':
groupedOffers[1].push(offer);
break;
case 'landscape':
groupedOffers[2].push(offer);
break;
}
}
setGroupedOffers(groupedOffers);
setSelectedGroupIndex(0);
setSelectedOfferForGroup([ groupedOffers[0][0], groupedOffers[1][0], groupedOffers[2][0] ]);
}, [ currentPage ]);
useEffect(() =>
{
if((selectedGroupIndex === -1) || !selectedOfferForGroup) return;
setCurrentOffer(selectedOfferForGroup[selectedGroupIndex]);
}, [ selectedGroupIndex, selectedOfferForGroup, setCurrentOffer ]);
useEffect(() =>
{
if((selectedGroupIndex === -1) || !selectedOfferForGroup || !currentOffer) return;
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.extraData = selectedOfferForGroup[selectedGroupIndex].product.extraParam;
newValue.extraParamRequired = true;
return newValue;
});
}, [ currentOffer, selectedGroupIndex, selectedOfferForGroup, setPurchaseOptions ]);
useEffect(() =>
{
if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
}, [ selectedGroupIndex ]);
if(!groupedOffers || (selectedGroupIndex === -1)) return null;
const offers = groupedOffers[selectedGroupIndex];
return (
<>
<div className="relative inline-flex align-middle">
{ SPACES_GROUP_NAMES.map((name, index) => <Button key={ index } active={ (selectedGroupIndex === index) } onClick={ event => setSelectedGroupIndex(index) }>{ LocalizeText(`catalog.spaces.tab.${ name }`) }</Button>) }
</div>
<AutoGrid columnCount={ columnCount } innerRef={ elementRef } { ...rest }>
{ offers && (offers.length > 0) && offers.map((offer, index) => <CatalogGridOfferView key={ index } itemActive={ (currentOffer && (currentOffer === offer)) } offer={ offer } selectOffer={ offer => setSelectedOffer(offer) } />) }
{ children }
</AutoGrid>
</>
);
};
@@ -0,0 +1,46 @@
import { FC } from 'react';
import { FaCaretLeft, FaCaretRight } from 'react-icons/fa';
import { LocalizeText } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
const MIN_VALUE: number = 1;
const MAX_VALUE: number = 100;
export const CatalogSpinnerWidgetView: FC<{}> = props =>
{
const { currentOffer = null, purchaseOptions = null, setPurchaseOptions = null } = useCatalog();
const { quantity = 1 } = purchaseOptions;
const updateQuantity = (value: number) =>
{
if(isNaN(value)) value = 1;
value = Math.max(value, MIN_VALUE);
value = Math.min(value, MAX_VALUE);
if(value === quantity) return;
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.quantity = value;
return newValue;
});
};
if(!currentOffer || !currentOffer.bundlePurchaseAllowed) return null;
return (
<>
<Text>{ LocalizeText('catalog.bundlewidget.spinner.select.amount') }</Text>
<div className="flex items-center gap-1">
<FaCaretLeft className="text-black cursor-pointer fa-icon" onClick={ event => updateQuantity(quantity - 1) } />
<input className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none min-h-[17px] h-[17px] w-[28px] px-[4px] py-[0] text-right rounded-[.2rem]" type="number" value={ quantity } onChange={ event => updateQuantity(event.target.valueAsNumber) } />
<FaCaretRight className="text-black cursor-pointer fa-icon" onClick={ event => updateQuantity(quantity + 1) } />
</div>
</>
);
};
@@ -0,0 +1,20 @@
import { FC } from 'react';
import { Column, ColumnProps } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { CatalogPriceDisplayWidgetView } from './CatalogPriceDisplayWidgetView';
interface CatalogSimplePriceWidgetViewProps extends ColumnProps
{
}
export const CatalogTotalPriceWidget: FC<CatalogSimplePriceWidgetViewProps> = props =>
{
const { gap = 1, ...rest } = props;
const { currentOffer = null } = useCatalog();
return (
<Column gap={ gap } { ...rest }>
<CatalogPriceDisplayWidgetView offer={ currentOffer } />
</Column>
);
};
@@ -0,0 +1,99 @@
import { GetAvatarRenderManager, GetSessionDataManager, Vector3d } from '@nitrots/nitro-renderer';
import { FC, useEffect } from 'react';
import { FurniCategory, Offer, ProductTypeEnum } from '../../../../../api';
import { AutoGrid, Column, LayoutGridItem, LayoutRoomPreviewerView } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
export const CatalogViewProductWidgetView: FC<{}> = props =>
{
const { currentOffer = null, roomPreviewer = null, purchaseOptions = null } = useCatalog();
const { previewStuffData = null } = purchaseOptions;
useEffect(() =>
{
if(!currentOffer || (currentOffer.pricingModel === Offer.PRICING_MODEL_BUNDLE) || !roomPreviewer) return;
const product = currentOffer.product;
if(!product) return;
roomPreviewer.reset(false);
switch(product.productType)
{
case ProductTypeEnum.FLOOR: {
if(!product.furnitureData) return;
if(product.furnitureData.specialType === FurniCategory.FIGURE_PURCHASABLE_SET)
{
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(GetAvatarRenderManager().isValidFigureSetForGender(part, GetSessionDataManager().gender)) figureSets.push(part);
}
const figureString = GetAvatarRenderManager().getFigureStringWithFigureIds(GetSessionDataManager().figure, GetSessionDataManager().gender, figureSets);
roomPreviewer.addAvatarIntoRoom(figureString, product.productClassId);
}
else
{
roomPreviewer.addFurnitureIntoRoom(product.productClassId, new Vector3d(90), previewStuffData, product.extraParam);
}
return;
}
case ProductTypeEnum.WALL: {
if(!product.furnitureData) return;
switch(product.furnitureData.specialType)
{
case FurniCategory.FLOOR:
roomPreviewer.updateObjectRoom(product.extraParam);
return;
case FurniCategory.WALL_PAPER:
roomPreviewer.updateObjectRoom(null, product.extraParam);
return;
case FurniCategory.LANDSCAPE: {
roomPreviewer.updateObjectRoom(null, null, product.extraParam);
const furniData = GetSessionDataManager().getWallItemDataByName('window_double_default');
if(furniData) roomPreviewer.addWallItemIntoRoom(furniData.id, new Vector3d(90), furniData.customParams);
return;
}
default:
roomPreviewer.updateObjectRoom('default', 'default', 'default');
roomPreviewer.addWallItemIntoRoom(product.productClassId, new Vector3d(90), product.extraParam);
return;
}
}
case ProductTypeEnum.ROBOT:
roomPreviewer.addAvatarIntoRoom(product.extraParam, 0);
return;
case ProductTypeEnum.EFFECT:
roomPreviewer.addAvatarIntoRoom(GetSessionDataManager().figure, product.productClassId);
return;
}
}, [ currentOffer, previewStuffData, roomPreviewer ]);
if(!currentOffer) return null;
if(currentOffer.pricingModel === Offer.PRICING_MODEL_BUNDLE)
{
return (
<Column fit className="bg-muted p-2 rounded" overflow="hidden">
<AutoGrid fullWidth className="nitro-catalog-layout-bundle-grid" columnCount={ 4 }>
{ (currentOffer.products.length > 0) && currentOffer.products.map((product, index) =>
{
return <LayoutGridItem key={ index } itemCount={ product.productCount } itemImage={ product.getIconUrl(currentOffer) } />;
}) }
</AutoGrid>
</Column>
);
}
return <LayoutRoomPreviewerView height={ 140 } roomPreviewer={ roomPreviewer } />;
};
@@ -0,0 +1,16 @@
import { TargetedOfferData } from '@nitrots/nitro-renderer';
import { Dispatch, SetStateAction } from 'react';
import { GetConfigurationValue } from '../../../../api';
import { LayoutNotificationBubbleView, Text } from '../../../../common';
export const OfferBubbleView = (props: { offer: TargetedOfferData, setOpen: Dispatch<SetStateAction<boolean>> }) =>
{
const { offer = null, setOpen = null } = props;
if(!offer) return;
return <LayoutNotificationBubbleView fadesOut={ false } gap={ 2 } onClick={ evt => setOpen(true) } onClose={ null }>
<div className="nitro-targeted-offer-icon" style={ { backgroundImage: `url(${ GetConfigurationValue('image.library.url') + offer.iconImageUrl })` } }/>
<Text className="ubuntu-bold" variant="light">{ offer.title }</Text>
</LayoutNotificationBubbleView>;
};
@@ -0,0 +1,32 @@
import { GetTargetedOfferComposer, TargetedOfferData, TargetedOfferEvent } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { SendMessageComposer } from '../../../../api';
import { useMessageEvent } from '../../../../hooks';
import { OfferBubbleView } from './OfferBubbleView';
import { OfferWindowView } from './OfferWindowView';
export const OfferView = () =>
{
const [ offer, setOffer ] = useState<TargetedOfferData>(null);
const [ opened, setOpened ] = useState<boolean>(false);
useMessageEvent<TargetedOfferEvent>(TargetedOfferEvent, evt =>
{
let parser = evt.getParser();
if(!parser) return;
setOffer(parser.data);
});
useEffect(() =>
{
SendMessageComposer(new GetTargetedOfferComposer());
}, []);
if(!offer) return;
return <>
{ opened ? <OfferWindowView offer={ offer } setOpen={ setOpened } /> : <OfferBubbleView offer={ offer } setOpen={ setOpened } /> }
</>;
};
@@ -0,0 +1,85 @@
import { GetTargetedOfferComposer, PurchaseTargetedOfferComposer, TargetedOfferData } from '@nitrots/nitro-renderer';
import { Dispatch, SetStateAction, useMemo, useState } from 'react';
import { FriendlyTime, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { usePurse } from '../../../../hooks';
export const OfferWindowView = (props: { offer: TargetedOfferData, setOpen: Dispatch<SetStateAction<boolean>> }) =>
{
const { offer = null, setOpen = null } = props;
const { getCurrencyAmount } = usePurse();
const [ amount, setAmount ] = useState<number>(1);
const canPurchase = useMemo(() =>
{
let credits = false;
let points = false;
let limit = false;
if(offer.priceInCredits > 0) credits = getCurrencyAmount(-1) >= offer.priceInCredits;
if(offer.priceInActivityPoints > 0) points = getCurrencyAmount(offer.activityPointType) >= offer.priceInActivityPoints;
else points = true;
if(offer.purchaseLimit > 0) limit = true;
return (credits && points && limit);
}, [ offer, getCurrencyAmount ]);
const expirationTime = () =>
{
let expirationTime = Math.max(0, (offer.expirationTime - Date.now()) / 1000);
return FriendlyTime.format(expirationTime);
};
const buyOffer = () =>
{
SendMessageComposer(new PurchaseTargetedOfferComposer(offer.id, amount));
SendMessageComposer(new GetTargetedOfferComposer());
};
if(!offer) return;
return <NitroCardView className="nitro-targeted-offer" theme="primary-slim" uniqueKey="targeted-offer">
<NitroCardHeaderView headerText={ LocalizeText(offer.title) } onCloseClick={ event => setOpen(false) } />
<div className="container-fluid p-1 relative justify-center items-center cursor-pointer gap-3 bg-danger">
{ LocalizeText('targeted.offer.timeleft', [ 'timeleft' ], [ expirationTime() ]) }
</div>
<NitroCardContentView gap={ 1 }>
<Flex fullHeight gap={ 1 }>
<Flex column className="w-75 text-black" gap={ 1 }>
<Column fullHeight className="bg-warning p-2">
<h4>
{ LocalizeText(offer.title) }
</h4>
<div dangerouslySetInnerHTML={ { __html: offer.description } } />
</Column>
<Flex alignItems="center" alignSelf="center" gap={ 2 } justifyContent="center">
{ offer.purchaseLimit > 1 &&
<div className="flex gap-1">
<Text variant="muted">{ LocalizeText('catalog.bundlewidget.quantity') }</Text>
<input max={ offer.purchaseLimit } min={ 1 } type="number" value={ amount } onChange={ evt => setAmount(parseInt(evt.target.value)) } />
</div> }
<Button disabled={ !canPurchase } variant="primary" onClick={ () => buyOffer() }>{ LocalizeText('targeted.offer.button.buy') }</Button>
</Flex>
</Flex>
<div className="w-50 h-full" style={ { background: `url(${ GetConfigurationValue('image.library.url') + offer.imageUrl }) no-repeat center` } } />
</Flex>
<Flex column alignItems="center" className="price-ray absolute" justifyContent="center">
<Text>{ LocalizeText('targeted.offer.price.label') }</Text>
{ offer.priceInCredits > 0 &&
<div className="flex gap-1">
<Text variant="light">{ offer.priceInCredits }</Text>
<LayoutCurrencyIcon type={ -1 } />
</div> }
{ offer.priceInActivityPoints > 0 &&
<div className="flex gap-1">
<Text className="ubuntu-bold" variant="light">+{ offer.priceInActivityPoints }</Text> <LayoutCurrencyIcon type={ offer.activityPointType } />
</div> }
</Flex>
</NitroCardContentView>
</NitroCardView>;
};
@@ -0,0 +1,114 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { ChatEntryType, LocalizeText } from '../../api';
import { Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useChatHistory } from '../../hooks';
import { NitroInput } from '../../layout';
export const ChatHistoryView: FC<{}> = props => {
const [isVisible, setIsVisible] = useState(false);
const [searchText, setSearchText] = useState<string>('');
const {chatHistory = []} = useChatHistory();
const elementRef = useRef<HTMLDivElement>(null);
const isFirstRender = useRef(true);
const prevChatLength = useRef<number>(0);
const filteredChatHistory = useMemo(() => {
let result = chatHistory;
if (searchText.length > 0) {
const text = searchText.toLowerCase();
result = chatHistory.filter(entry =>
(entry.message && entry.message.toLowerCase().includes(text)) ||
(entry.name && entry.name.toLowerCase().includes(text))
);
}
return [...result];
}, [chatHistory, searchText]);
useEffect(() => {
if (!elementRef.current || !isVisible) return;
const element = elementRef.current;
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
const isAtBottom = maxScrollTop === 0 || Math.abs(element.scrollTop - maxScrollTop) <= 50;
if (isFirstRender.current) {
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
isFirstRender.current = false;
} else if (filteredChatHistory.length > prevChatLength.current) {
element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' });
}
prevChatLength.current = filteredChatHistory.length;
}, [filteredChatHistory, isVisible]);
useEffect(() => {
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) => {
const parts = url.split('/');
if (parts.length < 2) return;
switch (parts[1]) {
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
return;
}
},
eventUrlPrefix: 'chat-history/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
if (!isVisible) return null;
return (
<NitroCardView className="nitro-chat-history" theme="primary-slim" uniqueKey="chat-history">
<NitroCardHeaderView headerText={LocalizeText('room.chathistory.button.text')} onCloseClick={event => setIsVisible(false)} />
<NitroCardContentView className="nitro-card-content" gap={2} overflow="hidden" style={{ height: 'calc(100% - 40px)', display: 'flex', flexDirection: 'column' }}>
<NitroInput placeholder={LocalizeText('generic.search')} type="text" value={searchText} onChange={event => setSearchText(event.target.value)} />
<div ref={elementRef} style={{ flex: 1, overflowY: 'auto', background: 'inherit' }}>
{filteredChatHistory.map((row, index) => (
<Flex key={index} alignItems="center" className="p-1" gap={2}>
<Text variant="gray">{row.timestamp}</Text>
{row.type === ChatEntryType.TYPE_CHAT && (
<div className="bubble-container" style={{position: 'relative', display: 'inline-flex', alignItems: 'center'}}>
<div
className={`chat-bubble bubble-${row.style} type-${row.chatType}`}
style={{ maxWidth: '100%', backgroundColor: row.style === 0 ? row.color : 'transparent', position: 'relative', zIndex: 1 }}>
<div className="user-container">
{row.imageUrl && row.imageUrl.length > 0 && (
<div className="user-image" style={{backgroundImage: `url(${row.imageUrl})`}} />
)}
</div>
<div className="chat-content">
<b className="mr-1 username" dangerouslySetInnerHTML={{__html: `${row.name}: `}} />
<span className="message" dangerouslySetInnerHTML={{__html: `${row.message}`}} />
</div>
</div>
</div>
)}
{row.type === ChatEntryType.TYPE_ROOM_INFO && (
<>
<i className="nitro-icon icon-small-room" />
<Text grow textBreak wrap>{row.name}</Text>
</>
)}
</Flex>
))}
</div>
</NitroCardContentView>
</NitroCardView>
);
};
@@ -0,0 +1,22 @@
import { createContext, Dispatch, FC, ProviderProps, SetStateAction, useContext } from 'react';
import { IFloorplanSettings } from './common/IFloorplanSettings';
import { IVisualizationSettings } from './common/IVisualizationSettings';
interface IFloorplanEditorContext
{
originalFloorplanSettings: IFloorplanSettings;
setOriginalFloorplanSettings: Dispatch<SetStateAction<IFloorplanSettings>>;
visualizationSettings: IVisualizationSettings;
setVisualizationSettings: Dispatch<SetStateAction<IVisualizationSettings>>;
}
const FloorplanEditorContext = createContext<IFloorplanEditorContext>({
originalFloorplanSettings: null,
setOriginalFloorplanSettings: null,
visualizationSettings: null,
setVisualizationSettings: null
});
export const FloorplanEditorContextProvider: FC<ProviderProps<IFloorplanEditorContext>> = props => <FloorplanEditorContext.Provider { ...props } />;
export const useFloorplanEditorContext = () => useContext(FloorplanEditorContext);

Some files were not shown because too many files have changed in this diff Show More