🆙 Take #3 desktop view catalog is now 100%

This commit is contained in:
duckietm
2026-06-05 16:31:59 +02:00
parent f4d41dd3c9
commit fff4c0bca6
8 changed files with 283 additions and 213 deletions
@@ -1,8 +1,8 @@
import { ColorConverter } from '@nitrots/nitro-renderer';
import { FC, useMemo, useState } from 'react';
import { FaFillDrip } from 'react-icons/fa';
import { IPurchasableOffer, SanitizeHtml } from '../../../../../api';
import { AutoGrid, Button, Column, Grid, LayoutGridItem, Text } from '../../../../../common';
import { FaExchangeAlt, FaFillDrip, FaSyncAlt } from 'react-icons/fa';
import { IPurchasableOffer, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
import { AutoGrid, Button, Column, LayoutGridItem, Text } from '../../../../../common';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
@@ -22,7 +22,7 @@ export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps
{
const { page = null } = props;
const [ colorableItems, setColorableItems ] = useState<Map<string, number[]>>(new Map<string, number[]>());
const { currentOffer = null } = useCatalogData();
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
const { setCurrentOffer = null } = useCatalogUiState();
const [ colorsShowing, setColorsShowing ] = useState<boolean>(false);
@@ -132,46 +132,59 @@ export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps
}, [ 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: SanitizeHtml(page.localization.getText(0)) } } />
</> }
{ currentOffer &&
<>
<div className="relative overflow-hidden">
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 inset-e-1" position="absolute" />
{ currentOffer.product.furnitureData.hasIndexedColor &&
<Button className="bottom-1 inset-s-1" position="absolute" onClick={ event => setColorsShowing(prev => !prev) }>
<FaFillDrip className="fa-icon" />
</Button> }
<Column overflow="hidden">
{ /* Top: two visible rows of furni tiles. Tile is 70px tall
and the AutoGrid handles its own overflow if there are
more than two rows worth of offers. */ }
<div className="shrink-0" style={ { maxHeight: 154 } }>
{ (!colorsShowing || !currentOffer || !colorableItems.has(currentOffer.product.furnitureData.className)) &&
<AutoGrid columnCount={ 7 } columnMinHeight={ 70 } columnMinWidth={ 45 }>
{ 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 } />) }
</AutoGrid> }
{ (colorsShowing && currentOffer && colorableItems.has(currentOffer.product.furnitureData.className)) &&
<div className="nitro-catalog-classic-color-swatches flex flex-wrap gap-1 p-2 overflow-auto">
{ 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) } />) }
</div> }
</div>
{ /* Bottom: preview pane stacked under the grid. Mirrors the
default-3x3 split (preview on the left, offer info on the
right) so the rotate/state buttons and Buy/Gift actions
sit where the user expects. */ }
{ !currentOffer &&
<Column center grow overflow="hidden">
{ !!page.localization.getImage(1) && <img alt="" src={ page.localization.getImage(1) } /> }
<Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(page.localization.getText(0)) } } />
</Column> }
{ currentOffer &&
<div className="nitro-catalog-classic-offer-panel flex flex-col items-center grow overflow-hidden gap-2">
<div className="nitro-catalog-classic-offer-preview relative flex items-center justify-center overflow-hidden">
{ (currentOffer.product.productType !== ProductTypeEnum.BADGE) &&
<>
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-rotate" onClick={ () => roomPreviewer?.changeRoomObjectDirection() }>
<FaSyncAlt />
</button>
<button className="nitro-catalog-classic-preview-btn nitro-catalog-classic-preview-state" onClick={ () => roomPreviewer?.changeRoomObjectState() }>
<FaExchangeAlt />
</button>
</> }
<CatalogViewProductWidgetView />
<CatalogAddOnBadgeWidgetView className="bg-muted rounded bottom-1 inset-e-1" position="absolute" />
{ currentOffer.product.furnitureData.hasIndexedColor &&
<Button className="bottom-1 inset-s-1" position="absolute" onClick={ event => setColorsShowing(prev => !prev) }>
<FaFillDrip className="fa-icon" />
</Button> }
</div>
<div className="w-full max-w-[360px] flex flex-col gap-2 px-1">
<CatalogLimitedItemWidgetView />
<Text truncate>{ currentOffer.localizationName }</Text>
<div className="flex justify-between items-center">
<CatalogSpinnerWidgetView />
<CatalogTotalPriceWidget alignItems="end" justifyContent="end" />
</div>
<Column className="grow!" gap={ 1 }>
<CatalogLimitedItemWidgetView />
<Text truncate className="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>
<CatalogPurchaseWidgetView />
</div>
</div> }
</Column>
);
};
@@ -291,7 +291,7 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
{ LocalizeText('catalog.pets.back.breeds') }
</button> }
</div>
<div className="grid grid-cols-6 gap-1">
<div className={ colorsShowing ? 'nitro-catalog-classic-color-swatches flex flex-wrap gap-1 p-2 overflow-auto' : 'grid grid-cols-6 gap-1' }>
{ !colorsShowing && (sellablePalettes.length > 0) && sellablePalettes.map((palette, index) => (
<LayoutGridItem
key={ index }
@@ -303,10 +303,12 @@ export const CatalogLayoutPetView: FC<CatalogLayoutProps> = props =>
</LayoutGridItem>
)) }
{ colorsShowing && (sellableColors.length > 0) && sellableColors.map((colorSet, index) => (
<div
<LayoutGridItem
key={ index }
className={ `w-full aspect-square rounded border-2 cursor-pointer transition-all ${ selectedColorIndex === index ? 'border-primary scale-110 shadow-md' : 'border-card-grid-item-border hover:border-primary/50' }` }
style={ { backgroundColor: `#${ ColorConverter.int2rgb(colorSet[0]) }` } }
itemHighlight
className="clear-bg"
itemActive={ (selectedColorIndex === index) }
itemColor={ ColorConverter.int2rgb(colorSet[0]) }
onClick={ () => setSelectedColorIndex(index) }
/>
)) }
@@ -22,85 +22,98 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
roomPreviewer.updateObjectRoom('default', 'default', 'default');
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
switch(product.productType)
const populate = () =>
{
case ProductTypeEnum.FLOOR: {
if(!product.furnitureData) return;
switch(product.productType)
{
case ProductTypeEnum.FLOOR: {
if(!product.furnitureData) return;
const furniData = GetSessionDataManager().getFloorItemData(product.furnitureData.id);
const isPurchasableClothing = (product.furnitureData.specialType === FurniCategory.FIGURE_PURCHASABLE_SET);
const hasResolvableFigureSets = (() =>
{
if(!furniData || !furniData.customParams || !furniData.customParams.length) return false;
const parts = furniData.customParams.split(',').map(value => parseInt(value));
for(const part of parts)
const furniData = GetSessionDataManager().getFloorItemData(product.furnitureData.id);
const isPurchasableClothing = (product.furnitureData.specialType === FurniCategory.FIGURE_PURCHASABLE_SET);
const hasResolvableFigureSets = (() =>
{
if(isNaN(part)) continue;
if(!furniData || !furniData.customParams || !furniData.customParams.length) return false;
if(GetAvatarRenderManager().structureData?.getFigurePartSet(part)) return true;
}
const parts = furniData.customParams.split(',').map(value => parseInt(value));
return false;
})();
for(const part of parts)
{
if(isNaN(part)) continue;
if(isPurchasableClothing || hasResolvableFigureSets)
{
const customParts = furniData.customParams.split(',').map(value => parseInt(value));
const figureSets: number[] = [];
if(GetAvatarRenderManager().structureData?.getFigurePartSet(part)) return true;
}
for(const part of customParts)
return false;
})();
if(isPurchasableClothing || hasResolvableFigureSets)
{
if(isNaN(part)) continue;
const customParts = furniData.customParams.split(',').map(value => parseInt(value));
const figureSets: number[] = [];
if(GetAvatarRenderManager().isValidFigureSetForGender(part, GetSessionDataManager().gender)) figureSets.push(part);
for(const part of customParts)
{
if(isNaN(part)) continue;
if(GetAvatarRenderManager().isValidFigureSetForGender(part, GetSessionDataManager().gender)) figureSets.push(part);
}
const figureString = BuildPurchasableClothingFigure(GetSessionDataManager().figure, figureSets);
roomPreviewer.addAvatarIntoRoom(figureString, product.productClassId);
}
const figureString = BuildPurchasableClothingFigure(GetSessionDataManager().figure, 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;
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
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;
else
{
roomPreviewer.addFurnitureIntoRoom(product.productClassId, new Vector3d(90), previewStuffData, product.extraParam);
}
default:
roomPreviewer.updateObjectRoom('default', 'default', 'default');
roomPreviewer.addWallItemIntoRoom(product.productClassId, new Vector3d(90), product.extraParam);
return;
return;
}
case ProductTypeEnum.WALL: {
if(!product.furnitureData) return;
roomPreviewer.updateRoomWallsAndFloorVisibility(true, true);
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;
}
case ProductTypeEnum.ROBOT:
roomPreviewer.addAvatarIntoRoom(product.extraParam, 0);
return;
case ProductTypeEnum.EFFECT:
roomPreviewer.addAvatarIntoRoom(GetSessionDataManager().figure, product.productClassId);
return;
}
};
populate();
// RoomPreviewer.addFurnitureIntoRoom / addAvatarIntoRoom flip
// _automaticStateChange to true, which makes the ticker advance
// the room object's state every AUTOMATIC_STATE_CHANGE_INTERVAL.
// In the catalog we want the preview to sit still until the
// user clicks the state button explicitly - turn it back off
// after populate() runs.
roomPreviewer.setAutomaticStateChange(false);
}, [ currentOffer, previewStuffData, roomPreviewer ]);
if(!currentOffer) return null;
@@ -119,5 +132,11 @@ export const CatalogViewProductWidgetView: FC<{}> = props =>
);
}
return <LayoutRoomPreviewerView height={ 240 } roomPreviewer={ roomPreviewer } />;
// Re-mount the previewer whenever the offer changes so the render
// latch / texture handle in LayoutRoomPreviewerView resets cleanly.
// Without this a single broken offer (e.g. blackhole's Pixi filter
// crash) latches the previewer permanently and every following
// offer paints nothing - the singleton roomPreviewer + 240px height
// keep the same component mounted otherwise.
return <LayoutRoomPreviewerView key={ currentOffer?.offerId } height={ 240 } roomPreviewer={ roomPreviewer } />;
};