|
|
@@ -0,0 +1,1517 @@
|
|
|
+import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
+import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
|
+import { useTranslation } from 'react-i18next';
|
|
|
+import { px2vmin, px2vw } from '@/utils/viewport';
|
|
|
+import { getOssUrl } from '@/utils/oss';
|
|
|
+import { wrapTrackedClick } from '@/utils/tracking';
|
|
|
+import { onScreenResize, updateTooSmallFlag } from '@/utils/screenSize';
|
|
|
+import {
|
|
|
+ ApiError,
|
|
|
+ ensureSession,
|
|
|
+ equipChatbotFurniture,
|
|
|
+ getChatbotLayout,
|
|
|
+ getUserInfo,
|
|
|
+ getChatbotShopCatalog,
|
|
|
+ type ChatbotLayoutBackdrop,
|
|
|
+ type ChatbotLayoutItem,
|
|
|
+ type ChatbotBackdropSlot,
|
|
|
+ type ChatbotShopCatalogItem,
|
|
|
+ unequipChatbotFurniture,
|
|
|
+ updateChatbotBackdrop,
|
|
|
+} from '@/utils/api';
|
|
|
+import '@/styles/chatbot-fix.css';
|
|
|
+
|
|
|
+const defaultBackdropImageName = 'init3.png';
|
|
|
+const backButtonIcon = getOssUrl('backbutn-icon.png');
|
|
|
+const lumiOutfit1ShopImg = getOssUrl('Lumi-outfit-1.png');
|
|
|
+const secondaryDefaultButton = getOssUrl('secondary-default.png');
|
|
|
+const primaryDefaultButton = getOssUrl('primary-default.png');
|
|
|
+/** 与 Profile Lumi 背包「家具 / 服装」卡片一致 */
|
|
|
+const chairBagImg = getOssUrl('Chair.png');
|
|
|
+const glovesBagImg = getOssUrl('Gloves.png');
|
|
|
+const bagAllImg = getOssUrl('bag-all.svg');
|
|
|
+const bagTableCabinetImg = getOssUrl('bag_table_cabinet.svg');
|
|
|
+const bagWindowImg = getOssUrl('bag-window.svg');
|
|
|
+const bagSeatSofaImg = getOssUrl('bag-seat_sofa.svg');
|
|
|
+const bagWallpaperImg = getOssUrl('bag-wallpaper.svg');
|
|
|
+const bagPropImg = getOssUrl('bag-prop.svg');
|
|
|
+const shopBagIcon = getOssUrl('Icon-shopbag.svg');
|
|
|
+const conversationEnergyStoneCount = getOssUrl('conversion-energy-stone-count.png');
|
|
|
+const missionEnergyStoneCountImg = getOssUrl('mission-energy-stone-count.png');
|
|
|
+
|
|
|
+type FurnitureFilterKey =
|
|
|
+ | 'all'
|
|
|
+ | 'table_cabinet'
|
|
|
+ | 'window'
|
|
|
+ | 'seat_sofa'
|
|
|
+ | 'wallpaper'
|
|
|
+ | 'prop';
|
|
|
+
|
|
|
+const FURNITURE_FILTERS: Array<{
|
|
|
+ key: FurnitureFilterKey;
|
|
|
+ icon: string;
|
|
|
+ ariaLabel: string;
|
|
|
+}> = [
|
|
|
+ { key: 'all', icon: bagAllImg, ariaLabel: 'all furniture' },
|
|
|
+ { key: 'table_cabinet', icon: bagTableCabinetImg, ariaLabel: 'table or cabinet' },
|
|
|
+ { key: 'window', icon: bagWindowImg, ariaLabel: 'window' },
|
|
|
+ { key: 'seat_sofa', icon: bagSeatSofaImg, ariaLabel: 'seat or sofa' },
|
|
|
+ { key: 'wallpaper', icon: bagWallpaperImg, ariaLabel: 'wallpaper' },
|
|
|
+ { key: 'prop', icon: bagPropImg, ariaLabel: 'other furniture' },
|
|
|
+];
|
|
|
+
|
|
|
+const CHATBOT_Z_INDEX = {
|
|
|
+ GRID_BACKDROP: 0,
|
|
|
+ GRID_LINES: 1,
|
|
|
+ GRID_DYNAMIC_MIN: 3,
|
|
|
+} as const;
|
|
|
+const SLOT_HIGHLIGHT_COLORS = [
|
|
|
+ '#00FF62',
|
|
|
+ '#FFB020',
|
|
|
+ '#4DA3FF',
|
|
|
+ '#FF5FA2',
|
|
|
+ '#A56BFF',
|
|
|
+ '#00D1FF',
|
|
|
+ '#FFD60A',
|
|
|
+ '#FF6B6B',
|
|
|
+] as const;
|
|
|
+
|
|
|
+type FurnitureActionModalMode = 'equip' | 'remove';
|
|
|
+
|
|
|
+/** 仅允许应用内相对路径,避免 open redirect */
|
|
|
+function parseShopReturnPath(state: unknown): string | null {
|
|
|
+ if (!state || typeof state !== 'object') return null;
|
|
|
+ const from = (state as { from?: unknown }).from;
|
|
|
+ if (typeof from !== 'string' || from.length === 0 || from.length > 512) return null;
|
|
|
+ if (!from.startsWith('/') || from.startsWith('//')) return null;
|
|
|
+ if (from.includes('://')) return null;
|
|
|
+ return from.split('#')[0] || null;
|
|
|
+}
|
|
|
+
|
|
|
+export const LumiShopPage = () => {
|
|
|
+ const navigate = useNavigate();
|
|
|
+ const location = useLocation();
|
|
|
+ const [searchParams] = useSearchParams();
|
|
|
+ const { t, i18n } = useTranslation('common');
|
|
|
+ const { t: tProfile } = useTranslation('profile');
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ updateTooSmallFlag();
|
|
|
+ return onScreenResize(() => {
|
|
|
+ updateTooSmallFlag();
|
|
|
+ });
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const handleBack = useCallback(() => {
|
|
|
+ const target = parseShopReturnPath(location.state);
|
|
|
+ if (target) {
|
|
|
+ navigate(target);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ navigate(-1);
|
|
|
+ }, [navigate, location.state]);
|
|
|
+
|
|
|
+ const onBackClick = useMemo(
|
|
|
+ () => wrapTrackedClick('lumi_shop', 'back', handleBack),
|
|
|
+ [handleBack],
|
|
|
+ );
|
|
|
+
|
|
|
+ const handleChatNow = useCallback(() => {
|
|
|
+ navigate('/chatbot');
|
|
|
+ }, [navigate]);
|
|
|
+
|
|
|
+ const onChatNowClick = useMemo(
|
|
|
+ () => wrapTrackedClick('lumi_shop', 'chat_now', handleChatNow),
|
|
|
+ [handleChatNow],
|
|
|
+ );
|
|
|
+
|
|
|
+ const handlePurchaseGoChat = useCallback(() => {
|
|
|
+ setActivePurchaseItem(null);
|
|
|
+ navigate('/chatbot');
|
|
|
+ }, [navigate]);
|
|
|
+
|
|
|
+ const onPurchaseGoChatClick = useMemo(
|
|
|
+ () => wrapTrackedClick('lumi_shop', 'purchase_go_chat', handlePurchaseGoChat),
|
|
|
+ [handlePurchaseGoChat],
|
|
|
+ );
|
|
|
+
|
|
|
+ const [lumiBagTab, setLumiBagTab] = useState<'furniture' | 'outfits'>('furniture');
|
|
|
+ const [furnitureFilter, setFurnitureFilter] = useState<FurnitureFilterKey>('all');
|
|
|
+ const [furnitureCatalogItems, setFurnitureCatalogItems] = useState<ChatbotShopCatalogItem[]>([]);
|
|
|
+ const [conversationStoneCount, setConversationStoneCount] = useState<number>(0);
|
|
|
+ const [crystalStoneCount, setCrystalStoneCount] = useState<number>(0);
|
|
|
+ const [chatbotBackdrop, setChatbotBackdrop] = useState<ChatbotLayoutBackdrop | null>(null);
|
|
|
+ /** 首次拉取布局完成前不渲染默认幕布 init3,避免与真实幕布切换时产生「窗户闪一下」 */
|
|
|
+ const [chatbotLayoutReady, setChatbotLayoutReady] = useState(false);
|
|
|
+ const [chatbotLayoutItems, setChatbotLayoutItems] = useState<ChatbotLayoutItem[]>([]);
|
|
|
+ const [chatbotBackdropSlots, setChatbotBackdropSlots] = useState<ChatbotBackdropSlot[]>([]);
|
|
|
+ const [furnitureNotice, setFurnitureNotice] = useState<string>('');
|
|
|
+ const [activePurchaseItem, setActivePurchaseItem] = useState<ChatbotShopCatalogItem | null>(null);
|
|
|
+ const [activeFurnitureModalItem, setActiveFurnitureModalItem] = useState<ChatbotShopCatalogItem | null>(null);
|
|
|
+ const [furnitureModalMode, setFurnitureModalMode] = useState<FurnitureActionModalMode>('equip');
|
|
|
+ const [furnitureSubmitting, setFurnitureSubmitting] = useState<boolean>(false);
|
|
|
+ const [chatbotGrid, setChatbotGrid] = useState(() => ({
|
|
|
+ grid_rows: 5,
|
|
|
+ grid_cols: 4,
|
|
|
+ cell_width: 96,
|
|
|
+ cell_height: 96,
|
|
|
+ decor_area_width: 393,
|
|
|
+ decor_area_height: 482,
|
|
|
+ }));
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const tab = searchParams.get('tab');
|
|
|
+ setLumiBagTab(tab === 'outfits' ? 'outfits' : 'furniture');
|
|
|
+ }, [searchParams]);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ let mounted = true;
|
|
|
+ void ensureSession()
|
|
|
+ .then(({ userId }) => Promise.all([getChatbotShopCatalog(userId), getUserInfo(userId)]))
|
|
|
+ .then(([catalogRes, userInfoRes]) => {
|
|
|
+ if (!mounted) return;
|
|
|
+ if (catalogRes.success) {
|
|
|
+ setFurnitureCatalogItems(catalogRes.data || []);
|
|
|
+ }
|
|
|
+ if (userInfoRes.success && userInfoRes.data) {
|
|
|
+ setConversationStoneCount(userInfoRes.data.conversation_energy_stone_total ?? 0);
|
|
|
+ setCrystalStoneCount(userInfoRes.data.crystal_stone_total ?? 0);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ if (!mounted) return;
|
|
|
+ setFurnitureCatalogItems([]);
|
|
|
+ });
|
|
|
+ return () => {
|
|
|
+ mounted = false;
|
|
|
+ };
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const refreshChatbotLayout = useCallback(async () => {
|
|
|
+ try {
|
|
|
+ const { userId } = await ensureSession();
|
|
|
+ const res = await getChatbotLayout(userId);
|
|
|
+ if (!res.success || !res.data) return;
|
|
|
+ setChatbotBackdrop(res.data.backdrop ?? null);
|
|
|
+ setChatbotLayoutItems(res.data.items ?? []);
|
|
|
+ setChatbotBackdropSlots(res.data.slots ?? []);
|
|
|
+ setChatbotGrid({
|
|
|
+ grid_rows: res.data.grid_rows,
|
|
|
+ grid_cols: res.data.grid_cols,
|
|
|
+ cell_width: res.data.cell_width,
|
|
|
+ cell_height: res.data.cell_height,
|
|
|
+ decor_area_width: res.data.decor_area_width,
|
|
|
+ decor_area_height: res.data.decor_area_height,
|
|
|
+ });
|
|
|
+ } finally {
|
|
|
+ setChatbotLayoutReady(true);
|
|
|
+ }
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const refreshFurnitureCatalog = useCallback(async () => {
|
|
|
+ const { userId } = await ensureSession();
|
|
|
+ const res = await getChatbotShopCatalog(userId);
|
|
|
+ if (!res.success) return;
|
|
|
+ setFurnitureCatalogItems(res.data || []);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ let mounted = true;
|
|
|
+ void refreshChatbotLayout().catch(() => {
|
|
|
+ if (!mounted) return;
|
|
|
+ setChatbotBackdrop(null);
|
|
|
+ setChatbotLayoutItems([]);
|
|
|
+ setChatbotBackdropSlots([]);
|
|
|
+ });
|
|
|
+ return () => {
|
|
|
+ mounted = false;
|
|
|
+ };
|
|
|
+ }, [refreshChatbotLayout]);
|
|
|
+
|
|
|
+ const filteredFurnitureCatalogItems = useMemo(() => {
|
|
|
+ if (furnitureFilter === 'all') return furnitureCatalogItems;
|
|
|
+ return furnitureCatalogItems.filter((item) => item.biz_type === furnitureFilter);
|
|
|
+ }, [furnitureFilter, furnitureCatalogItems]);
|
|
|
+ const activeBackdropImageName =
|
|
|
+ chatbotBackdrop?.image_name ?? (chatbotLayoutReady ? defaultBackdropImageName : null);
|
|
|
+ const activeLayoutItems = chatbotLayoutItems;
|
|
|
+ const cellWidth = chatbotGrid.cell_width ?? 96;
|
|
|
+ const cellHeight = chatbotGrid.cell_height ?? 96;
|
|
|
+ const gridRows = chatbotGrid.grid_rows ?? 5;
|
|
|
+ const gridCols = chatbotGrid.grid_cols ?? 4;
|
|
|
+ const gridOriginY = 0;
|
|
|
+ /** 5×4 区整体在幕布层水平居中,槽位坐标相对网格左上角 */
|
|
|
+ const gridOriginX = 0;
|
|
|
+
|
|
|
+ const handleSelectFurnitureTab = useCallback(() => {
|
|
|
+ setLumiBagTab('furniture');
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const handleSelectOutfitsTab = useCallback(() => {
|
|
|
+ setLumiBagTab('outfits');
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const onFurnitureTabClick = useMemo(
|
|
|
+ () => wrapTrackedClick('lumi_shop', 'bag_furniture_tab', handleSelectFurnitureTab),
|
|
|
+ [handleSelectFurnitureTab],
|
|
|
+ );
|
|
|
+
|
|
|
+ const onOutfitsTabClick = useMemo(
|
|
|
+ () => wrapTrackedClick('lumi_shop', 'bag_outfits_tab', handleSelectOutfitsTab),
|
|
|
+ [handleSelectOutfitsTab],
|
|
|
+ );
|
|
|
+
|
|
|
+ const handleShopCartClick = useCallback(() => {
|
|
|
+ // 购物车页/流程待接入
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const onShopCartClick = useMemo(
|
|
|
+ () => wrapTrackedClick('lumi_shop', 'shop_cart', handleShopCartClick),
|
|
|
+ [handleShopCartClick],
|
|
|
+ );
|
|
|
+
|
|
|
+ const isZh = i18n.resolvedLanguage?.toLowerCase().startsWith('zh') ?? true;
|
|
|
+ const getCatalogItemDisplayName = useCallback(
|
|
|
+ (item: ChatbotShopCatalogItem) => {
|
|
|
+ return isZh ? item.name_zh || item.name || '' : item.name_en || item.name || '';
|
|
|
+ },
|
|
|
+ [isZh],
|
|
|
+ );
|
|
|
+
|
|
|
+ const formatCatalogEnergyPrice = useCallback(
|
|
|
+ (item: ChatbotShopCatalogItem) => {
|
|
|
+ const pe = item.price_energy ?? 0;
|
|
|
+ if (pe <= 0) return tProfile('lumiShopPriceFree');
|
|
|
+ const s = Number.isInteger(pe) ? String(pe) : pe.toFixed(2);
|
|
|
+ return tProfile('lumiShopPriceEnergy', { price: s });
|
|
|
+ },
|
|
|
+ [tProfile],
|
|
|
+ );
|
|
|
+
|
|
|
+ const formatCatalogEnergyCount = useCallback((item: ChatbotShopCatalogItem) => {
|
|
|
+ const pe = Number(item.price_energy ?? 0);
|
|
|
+ if (pe <= 0) return '0';
|
|
|
+ return Number.isInteger(pe) ? String(pe) : pe.toFixed(2);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const showFurnitureNotice = useCallback((message: string) => {
|
|
|
+ setFurnitureNotice(message);
|
|
|
+ window.setTimeout(() => {
|
|
|
+ setFurnitureNotice((current) => (current === message ? '' : current));
|
|
|
+ }, 2500);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const handleFurnitureCardClick = useCallback(
|
|
|
+ (item: ChatbotShopCatalogItem) => {
|
|
|
+ if (!item.is_owned) {
|
|
|
+ setActivePurchaseItem(item);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const isBackdrop = item.item_type === 'backdrop' || item.biz_type === 'wallpaper';
|
|
|
+ if (isBackdrop && item.is_in_use) {
|
|
|
+ showFurnitureNotice(tProfile('lumiWallpaperFixedNotice'));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isBackdrop && !item.is_in_use) {
|
|
|
+ void ensureSession()
|
|
|
+ .then(({ userId }) => updateChatbotBackdrop(userId, item.item_id))
|
|
|
+ .then(() => Promise.all([refreshChatbotLayout(), refreshFurnitureCatalog()]))
|
|
|
+ .catch(() => {
|
|
|
+ showFurnitureNotice(t('error.SERVER_ERROR'));
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setActiveFurnitureModalItem(item);
|
|
|
+ setFurnitureModalMode(item.is_in_use ? 'remove' : 'equip');
|
|
|
+ },
|
|
|
+ [refreshChatbotLayout, refreshFurnitureCatalog, showFurnitureNotice, t, tProfile],
|
|
|
+ );
|
|
|
+
|
|
|
+ const handleFurnitureModalConfirm = useCallback(() => {
|
|
|
+ if (!activeFurnitureModalItem || furnitureSubmitting) return;
|
|
|
+ setFurnitureSubmitting(true);
|
|
|
+ const actionPromise =
|
|
|
+ furnitureModalMode === 'remove'
|
|
|
+ ? ensureSession().then(({ userId }) => unequipChatbotFurniture(userId, activeFurnitureModalItem.item_id))
|
|
|
+ : ensureSession().then(({ userId }) => equipChatbotFurniture(userId, activeFurnitureModalItem.item_id));
|
|
|
+
|
|
|
+ void actionPromise
|
|
|
+ .then((res) => {
|
|
|
+ if (
|
|
|
+ furnitureModalMode === 'remove' &&
|
|
|
+ res.success &&
|
|
|
+ res.data &&
|
|
|
+ 'cascade_removed_count' in res.data &&
|
|
|
+ Number(res.data.cascade_removed_count || 0) > 0
|
|
|
+ ) {
|
|
|
+ showFurnitureNotice(res.data.cascade_notice || tProfile('lumiFurnitureCascadeRemovedNotice'));
|
|
|
+ }
|
|
|
+ return Promise.all([refreshChatbotLayout(), refreshFurnitureCatalog()]);
|
|
|
+ })
|
|
|
+ .then(() => {
|
|
|
+ setActiveFurnitureModalItem(null);
|
|
|
+ })
|
|
|
+ .catch((error: unknown) => {
|
|
|
+ if (error instanceof ApiError) {
|
|
|
+ if (error.code === 'SLOT_DEPENDENCY_NOT_SATISFIED') {
|
|
|
+ showFurnitureNotice(error.backendMessage || tProfile('lumiFurnitureDependencyNotMetNotice'));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ showFurnitureNotice(t(`error.${error.code}`));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ showFurnitureNotice(t('error.SERVER_ERROR'));
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ setFurnitureSubmitting(false);
|
|
|
+ });
|
|
|
+ }, [
|
|
|
+ activeFurnitureModalItem,
|
|
|
+ furnitureModalMode,
|
|
|
+ furnitureSubmitting,
|
|
|
+ refreshChatbotLayout,
|
|
|
+ refreshFurnitureCatalog,
|
|
|
+ showFurnitureNotice,
|
|
|
+ t,
|
|
|
+ tProfile,
|
|
|
+ ]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <div
|
|
|
+ className="chatbot-container"
|
|
|
+ style={{
|
|
|
+ position: 'relative',
|
|
|
+ width: '100%',
|
|
|
+ minHeight: '100dvh',
|
|
|
+ overflow: 'hidden',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 幕布舞台:与背景图同域的包含块,5×4 区相对本层(幕布区域)水平居中 */}
|
|
|
+ <div
|
|
|
+ className="absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ left: 0,
|
|
|
+ top: 0,
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ zIndex: 0,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {activeBackdropImageName ? (
|
|
|
+ <div
|
|
|
+ className="absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ left: 0,
|
|
|
+ top: 0,
|
|
|
+ zIndex: CHATBOT_Z_INDEX.GRID_BACKDROP,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={getOssUrl(activeBackdropImageName)}
|
|
|
+ alt={t('a11y.trees')}
|
|
|
+ className="absolute"
|
|
|
+ fetchPriority="low"
|
|
|
+ loading="lazy"
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ left: 0,
|
|
|
+ top: 0,
|
|
|
+ objectFit: 'cover',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {/* 5×4 单元格区域:宽=列数×格宽,相对幕布舞台水平居中;与格线/家具同一 vmin 基准 */}
|
|
|
+ <div
|
|
|
+ className="absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ width: px2vmin(gridCols * cellWidth),
|
|
|
+ top: 0,
|
|
|
+ bottom: 0,
|
|
|
+ zIndex: 1,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {lumiBagTab === 'furniture' && (
|
|
|
+ <div
|
|
|
+ className="absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ left: px2vmin(gridOriginX),
|
|
|
+ top: px2vmin(gridOriginY),
|
|
|
+ width: px2vmin(gridCols * cellWidth),
|
|
|
+ height: px2vmin(gridRows * cellHeight),
|
|
|
+ zIndex: CHATBOT_Z_INDEX.GRID_LINES,
|
|
|
+ border: '1px solid rgba(255, 255, 255, 0.35)',
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {Array.from({ length: gridCols - 1 }, (_, idx) => {
|
|
|
+ const col = idx + 1;
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={col}
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: 0,
|
|
|
+ left: px2vmin(col * cellWidth),
|
|
|
+ width: 1,
|
|
|
+ height: '100%',
|
|
|
+ backgroundColor: 'rgba(255, 255, 255, 0.35)',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ {Array.from({ length: gridRows - 1 }, (_, idx) => {
|
|
|
+ const row = idx + 1;
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={row}
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ left: 0,
|
|
|
+ top: px2vmin(row * cellHeight),
|
|
|
+ height: 1,
|
|
|
+ width: '100%',
|
|
|
+ backgroundColor: 'rgba(255, 255, 255, 0.35)',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {activeLayoutItems.map((item, idx) => {
|
|
|
+ const slotLeftPx = gridOriginX + (item.start_col - 1) * cellWidth;
|
|
|
+ const slotTopPx = gridOriginY + (item.start_row - 1) * cellHeight;
|
|
|
+ const slotWidthPx = item.size_cols * cellWidth;
|
|
|
+ const slotHeightPx = item.size_rows * cellHeight;
|
|
|
+ const isPixelSlot = item.render_mode === 'pixel';
|
|
|
+ const offsetX = item.offset_x ?? 0;
|
|
|
+ const offsetY = item.offset_y ?? 0;
|
|
|
+ const widthPx = isPixelSlot ? (item.pixel_width ?? slotWidthPx) : slotWidthPx;
|
|
|
+ const heightPx = isPixelSlot ? (item.pixel_height ?? slotHeightPx) : slotHeightPx;
|
|
|
+ const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
|
|
|
+ const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
|
|
|
+ return (
|
|
|
+ <img
|
|
|
+ key={`${item.image_name_snapshot}_${item.start_row}_${item.start_col}_${idx}`}
|
|
|
+ src={getOssUrl(item.image_name_snapshot)}
|
|
|
+ alt=""
|
|
|
+ className="absolute pointer-events-none"
|
|
|
+ fetchPriority="low"
|
|
|
+ loading="lazy"
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(widthPx),
|
|
|
+ height: px2vmin(heightPx),
|
|
|
+ left: px2vmin(leftPx),
|
|
|
+ top: px2vmin(topPx),
|
|
|
+ zIndex: Math.max(item.z_index ?? 2, CHATBOT_Z_INDEX.GRID_DYNAMIC_MIN),
|
|
|
+ objectFit: 'contain',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ })}
|
|
|
+
|
|
|
+ {lumiBagTab === 'furniture' &&
|
|
|
+ chatbotBackdropSlots.map((slot, idx) => {
|
|
|
+ const slotLeftPx = gridOriginX + (slot.start_col - 1) * cellWidth;
|
|
|
+ const slotTopPx = gridOriginY + (slot.start_row - 1) * cellHeight;
|
|
|
+ const slotWidthPx = slot.size_cols * cellWidth;
|
|
|
+ const slotHeightPx = slot.size_rows * cellHeight;
|
|
|
+ const isPixelSlot = slot.render_mode === 'pixel';
|
|
|
+ const offsetX = slot.offset_x ?? 0;
|
|
|
+ const offsetY = slot.offset_y ?? 0;
|
|
|
+ const widthPx = isPixelSlot ? (slot.pixel_width ?? slotWidthPx) : slotWidthPx;
|
|
|
+ const heightPx = isPixelSlot ? (slot.pixel_height ?? slotHeightPx) : slotHeightPx;
|
|
|
+ const leftPx = isPixelSlot ? slotLeftPx + offsetX : slotLeftPx;
|
|
|
+ const topPx = isPixelSlot ? slotTopPx + offsetY : slotTopPx;
|
|
|
+ const color = SLOT_HIGHLIGHT_COLORS[idx % SLOT_HIGHLIGHT_COLORS.length];
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={`slot-highlight-${slot.id}-${idx}`}
|
|
|
+ className="absolute pointer-events-none"
|
|
|
+ style={{
|
|
|
+ left: px2vmin(leftPx),
|
|
|
+ top: px2vmin(topPx),
|
|
|
+ width: px2vmin(widthPx),
|
|
|
+ height: px2vmin(heightPx),
|
|
|
+ border: `2PX solid ${color}`,
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ zIndex: 20,
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onBackClick}
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ left: px2vmin(24),
|
|
|
+ top: px2vmin(125),
|
|
|
+ width: px2vmin(32),
|
|
|
+ height: px2vmin(32),
|
|
|
+ padding: 0,
|
|
|
+ border: 'none',
|
|
|
+ background: 'none',
|
|
|
+ borderRadius: '50%',
|
|
|
+ zIndex: 100,
|
|
|
+ cursor: 'pointer',
|
|
|
+ }}
|
|
|
+ aria-label={t('a11y.back')}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={backButtonIcon}
|
|
|
+ alt=""
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ display: 'block',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ draggable={false}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {furnitureNotice ? (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'fixed',
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ bottom: px2vmin(365),
|
|
|
+ width: px2vmin(312),
|
|
|
+ minHeight: px2vmin(60),
|
|
|
+ maxHeight: px2vmin(120),
|
|
|
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
|
+ borderRadius: px2vmin(12),
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'flex-start',
|
|
|
+ justifyContent: 'center',
|
|
|
+ textAlign: 'center',
|
|
|
+ color: '#fff',
|
|
|
+ fontSize: px2vmin(14),
|
|
|
+ lineHeight: px2vmin(20),
|
|
|
+ padding: `${px2vmin(10)} ${px2vmin(10)}`,
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ zIndex: 10020,
|
|
|
+ overflowY: 'auto',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {furnitureNotice}
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {/* 底部弹窗 + 上方家具/服装切换(与 Profile Lumi 背包 icon 一致) */}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'fixed',
|
|
|
+ bottom: 0,
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ width: px2vw(393),
|
|
|
+ zIndex: 9999,
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ paddingLeft: px2vmin(24),
|
|
|
+ paddingRight: px2vmin(24),
|
|
|
+ marginBottom: px2vmin(10),
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'row',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'space-between',
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'row',
|
|
|
+ alignItems: 'center',
|
|
|
+ gap: px2vmin(3),
|
|
|
+ flexShrink: 0,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'relative',
|
|
|
+ width: px2vmin(82),
|
|
|
+ height: px2vmin(32),
|
|
|
+ flexShrink: 0,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={conversationEnergyStoneCount}
|
|
|
+ alt=""
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ display: 'block',
|
|
|
+ objectFit: 'contain',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <span
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: '50%',
|
|
|
+ left: '63%',
|
|
|
+ transform: 'translate(-50%, -50%)',
|
|
|
+ fontSize: px2vmin(10),
|
|
|
+ color: '#000',
|
|
|
+ fontWeight: 500,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {conversationStoneCount}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'relative',
|
|
|
+ width: px2vmin(82),
|
|
|
+ height: px2vmin(32),
|
|
|
+ flexShrink: 0,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={missionEnergyStoneCountImg}
|
|
|
+ alt=""
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ display: 'block',
|
|
|
+ objectFit: 'contain',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <span
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: '50%',
|
|
|
+ left: '63%',
|
|
|
+ transform: 'translate(-50%, -50%)',
|
|
|
+ fontSize: px2vmin(10),
|
|
|
+ color: '#000',
|
|
|
+ fontWeight: 500,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {crystalStoneCount}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ role="tablist"
|
|
|
+ aria-label={tProfile('lumiBagTitle')}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(80),
|
|
|
+ height: px2vmin(32),
|
|
|
+ borderRadius: px2vmin(12),
|
|
|
+ backgroundColor: 'rgba(0, 0, 0, 0.2)',
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'row',
|
|
|
+ alignItems: 'stretch',
|
|
|
+ padding: px2vmin(2),
|
|
|
+ gap: px2vmin(2),
|
|
|
+ flexShrink: 0,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ role="tab"
|
|
|
+ aria-selected={lumiBagTab === 'furniture'}
|
|
|
+ onClick={onFurnitureTabClick}
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ minWidth: 0,
|
|
|
+ margin: 0,
|
|
|
+ padding: 0,
|
|
|
+ border: 'none',
|
|
|
+ cursor: 'pointer',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ borderRadius: px2vmin(10),
|
|
|
+ backgroundColor:
|
|
|
+ lumiBagTab === 'furniture' ? 'rgba(245, 243, 240, 0.2)' : 'transparent',
|
|
|
+ WebkitTapHighlightColor: 'transparent',
|
|
|
+ }}
|
|
|
+ aria-label={tProfile('lumiBagFurniture')}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={chairBagImg}
|
|
|
+ alt=""
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(20),
|
|
|
+ height: px2vmin(20),
|
|
|
+ objectFit: 'contain',
|
|
|
+ display: 'block',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ role="tab"
|
|
|
+ aria-selected={lumiBagTab === 'outfits'}
|
|
|
+ onClick={onOutfitsTabClick}
|
|
|
+ style={{
|
|
|
+ flex: 1,
|
|
|
+ minWidth: 0,
|
|
|
+ margin: 0,
|
|
|
+ padding: 0,
|
|
|
+ border: 'none',
|
|
|
+ cursor: 'pointer',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ borderRadius: px2vmin(10),
|
|
|
+ backgroundColor:
|
|
|
+ lumiBagTab === 'outfits' ? 'rgba(245, 243, 240, 0.2)' : 'transparent',
|
|
|
+ WebkitTapHighlightColor: 'transparent',
|
|
|
+ }}
|
|
|
+ aria-label={tProfile('lumiBagOutfits')}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={glovesBagImg}
|
|
|
+ alt=""
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(20),
|
|
|
+ height: px2vmin(20),
|
|
|
+ objectFit: 'contain',
|
|
|
+ display: 'block',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onShopCartClick}
|
|
|
+ aria-label={tProfile('lumiShopCartA11y')}
|
|
|
+ style={{
|
|
|
+ flexShrink: 0,
|
|
|
+ margin: 0,
|
|
|
+ padding: 0,
|
|
|
+ border: 'none',
|
|
|
+ background: 'none',
|
|
|
+ cursor: 'pointer',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ width: px2vmin(32),
|
|
|
+ height: px2vmin(32),
|
|
|
+ WebkitTapHighlightColor: 'transparent',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={shopBagIcon}
|
|
|
+ alt=""
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(32),
|
|
|
+ height: px2vmin(32),
|
|
|
+ objectFit: 'contain',
|
|
|
+ display: 'block',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ height: px2vmin(318),
|
|
|
+ backgroundColor: '#34313D',
|
|
|
+ borderTopLeftRadius: px2vmin(32),
|
|
|
+ borderTopRightRadius: px2vmin(32),
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ paddingBottom: 'max(env(safe-area-inset-bottom, 0px), 0px)',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'relative',
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ overflow: 'hidden',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/* 服装 tab:提示文案 + 去聊天(家具商店 tab 不占用此区,把空间留给商品列表) */}
|
|
|
+ {lumiBagTab === 'outfits' ? (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(204),
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'column',
|
|
|
+ alignItems: 'center',
|
|
|
+ paddingLeft: px2vmin(32),
|
|
|
+ paddingRight: px2vmin(32),
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ zIndex: 10040,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <p
|
|
|
+ style={{
|
|
|
+ margin: 0,
|
|
|
+ width: '100%',
|
|
|
+ color: '#FFFFFF',
|
|
|
+ fontSize: px2vmin(14),
|
|
|
+ lineHeight: px2vmin(14),
|
|
|
+ textAlign: 'center',
|
|
|
+ fontWeight: 400,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {tProfile('lumiOutfitsShopHint')}
|
|
|
+ </p>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ marginTop: px2vmin(10),
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'row',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ columnGap: px2vmin(20),
|
|
|
+ width: '100%',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onChatNowClick}
|
|
|
+ onKeyDown={(e) => {
|
|
|
+ if (e.key === 'Enter' || e.key === ' ') {
|
|
|
+ e.preventDefault();
|
|
|
+ onChatNowClick();
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(312),
|
|
|
+ height: px2vmin(56),
|
|
|
+ padding: 0,
|
|
|
+ border: 'none',
|
|
|
+ borderRadius: px2vmin(12),
|
|
|
+ background: `url(${secondaryDefaultButton}) center/cover no-repeat`,
|
|
|
+ cursor: 'pointer',
|
|
|
+ transition: 'opacity 0.2s ease',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ fontFamily:
|
|
|
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
|
|
|
+ fontWeight: 500,
|
|
|
+ fontSize: px2vmin(16),
|
|
|
+ lineHeight: px2vmin(24),
|
|
|
+ color: '#fff',
|
|
|
+ WebkitTapHighlightColor: 'transparent',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {tProfile('chatNowButton')}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {/* 顶部 44:与能量石弹窗一致留出手势条区域 */}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ height: px2vmin(44),
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: px2vmin(32),
|
|
|
+ height: px2vmin(4),
|
|
|
+ backgroundColor: '#79747E',
|
|
|
+ borderRadius: px2vmin(4),
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 距弹窗顶部 44、高度 274(随弹窗总高 318) */}
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(44),
|
|
|
+ left: 0,
|
|
|
+ width: '100%',
|
|
|
+ height: px2vmin(274),
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {/** 服装行:距父区域顶部 32,宽 310,高 90,水平居中 */}
|
|
|
+ {lumiBagTab === 'furniture' ? (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ marginTop: px2vmin(12),
|
|
|
+ marginLeft: 'auto',
|
|
|
+ marginRight: 'auto',
|
|
|
+ width: px2vmin(310),
|
|
|
+ height: px2vmin(262),
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'column',
|
|
|
+ minHeight: 0,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: px2vmin(310),
|
|
|
+ height: px2vmin(40),
|
|
|
+ borderRadius: px2vmin(12),
|
|
|
+ backgroundColor: 'rgba(245, 243, 240, 0.1)',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'space-between',
|
|
|
+ gap: px2vmin(2),
|
|
|
+ padding: 0,
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ flexShrink: 0,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {FURNITURE_FILTERS.map((filter) => {
|
|
|
+ const selected = furnitureFilter === filter.key;
|
|
|
+ return (
|
|
|
+ <button
|
|
|
+ key={filter.key}
|
|
|
+ type="button"
|
|
|
+ onClick={() => setFurnitureFilter(filter.key)}
|
|
|
+ aria-label={filter.ariaLabel}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(44),
|
|
|
+ height: px2vmin(36),
|
|
|
+ borderRadius: px2vmin(12),
|
|
|
+ border: 'none',
|
|
|
+ padding: 0,
|
|
|
+ margin: 0,
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ backgroundColor: selected ? '#8F6CC9' : 'transparent',
|
|
|
+ cursor: 'pointer',
|
|
|
+ WebkitTapHighlightColor: 'transparent',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={filter.icon}
|
|
|
+ alt=""
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(16),
|
|
|
+ height: px2vmin(16),
|
|
|
+ objectFit: 'contain',
|
|
|
+ display: 'block',
|
|
|
+ opacity: selected ? 1 : 0.6,
|
|
|
+ filter: selected ? 'brightness(0) invert(1)' : 'none',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </button>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ marginTop: px2vmin(12),
|
|
|
+ width: '100%',
|
|
|
+ flex: 1,
|
|
|
+ minHeight: 0,
|
|
|
+ overflowY: 'auto',
|
|
|
+ WebkitOverflowScrolling: 'touch',
|
|
|
+ display: 'grid',
|
|
|
+ gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))',
|
|
|
+ gap: px2vmin(10),
|
|
|
+ alignContent: 'start',
|
|
|
+ paddingBottom: px2vmin(10),
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {filteredFurnitureCatalogItems.map((item) => (
|
|
|
+ <button
|
|
|
+ key={item.item_id}
|
|
|
+ type="button"
|
|
|
+ onClick={() => handleFurnitureCardClick(item)}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(90),
|
|
|
+ height: px2vmin(112),
|
|
|
+ borderRadius: px2vmin(16),
|
|
|
+ backgroundColor: 'rgba(245, 243, 240, 0.1)',
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'column',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ rowGap: px2vmin(4),
|
|
|
+ flexShrink: 0,
|
|
|
+ border:
|
|
|
+ item.is_in_use && !item.is_owned
|
|
|
+ ? '1PX solid rgb(191, 157, 248)'
|
|
|
+ : '1PX solid rgba(245, 243, 240, 0.28)',
|
|
|
+ justifySelf: 'center',
|
|
|
+ padding: 0,
|
|
|
+ cursor: 'pointer',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={getOssUrl(item.image_name)}
|
|
|
+ alt={item.name || ''}
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(64),
|
|
|
+ height: px2vmin(64),
|
|
|
+ objectFit: 'contain',
|
|
|
+ display: 'block',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'relative',
|
|
|
+ width: px2vmin(70),
|
|
|
+ height: px2vmin(20),
|
|
|
+ flexShrink: 0,
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={conversationEnergyStoneCount}
|
|
|
+ alt=""
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ objectFit: 'contain',
|
|
|
+ display: 'block',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ <span
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: '50%',
|
|
|
+ left: '60%',
|
|
|
+ transform: 'translate(-50%, -50%)',
|
|
|
+ fontSize: px2vmin(10),
|
|
|
+ color: '#000',
|
|
|
+ fontWeight: 500,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {formatCatalogEnergyCount(item)}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ marginTop: px2vmin(12),
|
|
|
+ marginLeft: 'auto',
|
|
|
+ marginRight: 'auto',
|
|
|
+ width: '100%',
|
|
|
+ display: 'flex',
|
|
|
+ flexDirection: 'row',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={lumiOutfit1ShopImg}
|
|
|
+ alt=""
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(160),
|
|
|
+ height: px2vmin(160),
|
|
|
+ objectFit: 'contain',
|
|
|
+ display: 'block',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {activePurchaseItem ? (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'fixed',
|
|
|
+ inset: 0,
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ zIndex: 10031,
|
|
|
+ }}
|
|
|
+ onClick={() => setActivePurchaseItem(null)}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ bottom: 0,
|
|
|
+ width: px2vw(393),
|
|
|
+ height: px2vmin(318),
|
|
|
+ backgroundColor: '#34313D',
|
|
|
+ borderTopLeftRadius: px2vmin(32),
|
|
|
+ borderTopRightRadius: px2vmin(32),
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ overflow: 'hidden',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ height: px2vmin(44),
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: px2vmin(32),
|
|
|
+ height: px2vmin(4),
|
|
|
+ backgroundColor: '#79747E',
|
|
|
+ borderRadius: px2vmin(4),
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(56),
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ width: px2vmin(80),
|
|
|
+ height: px2vmin(80),
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={getOssUrl(activePurchaseItem.image_name)}
|
|
|
+ alt={getCatalogItemDisplayName(activePurchaseItem)}
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ objectFit: 'contain',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(146),
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ margin: 0,
|
|
|
+ textAlign: 'center',
|
|
|
+ fontSize: px2vmin(22),
|
|
|
+ lineHeight: px2vmin(30),
|
|
|
+ color: '#FFFFFF',
|
|
|
+ fontWeight: 400,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {getCatalogItemDisplayName(activePurchaseItem)}
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <p
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(184),
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ width: px2vmin(312),
|
|
|
+ margin: 0,
|
|
|
+ textAlign: 'center',
|
|
|
+ color: 'rgba(255, 255, 255, 0.92)',
|
|
|
+ fontSize: px2vmin(16),
|
|
|
+ lineHeight: px2vmin(24),
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {formatCatalogEnergyPrice(activePurchaseItem)}
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(241),
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ columnGap: px2vmin(20),
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={onPurchaseGoChatClick}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(146),
|
|
|
+ height: px2vmin(56),
|
|
|
+ padding: 0,
|
|
|
+ border: 'none',
|
|
|
+ borderRadius: px2vmin(12),
|
|
|
+ background: `url(${primaryDefaultButton}) center/cover no-repeat`,
|
|
|
+ cursor: 'pointer',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ color: '#fff',
|
|
|
+ fontSize: px2vmin(16),
|
|
|
+ lineHeight: px2vmin(24),
|
|
|
+ fontWeight: 500,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {tProfile('lumiShopPurchaseButton')}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setActivePurchaseItem(null)}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(146),
|
|
|
+ height: px2vmin(56),
|
|
|
+ padding: 0,
|
|
|
+ border: 'none',
|
|
|
+ borderRadius: px2vmin(12),
|
|
|
+ background: `url(${secondaryDefaultButton}) center/cover no-repeat`,
|
|
|
+ cursor: 'pointer',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ color: '#fff',
|
|
|
+ fontSize: px2vmin(16),
|
|
|
+ lineHeight: px2vmin(24),
|
|
|
+ fontWeight: 500,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {tProfile('lumiFurnitureCancel')}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ {activeFurnitureModalItem ? (
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'fixed',
|
|
|
+ inset: 0,
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ zIndex: 10030,
|
|
|
+ }}
|
|
|
+ onClick={() => setActiveFurnitureModalItem(null)}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ bottom: 0,
|
|
|
+ width: px2vw(393),
|
|
|
+ height: px2vmin(318),
|
|
|
+ backgroundColor: '#34313D',
|
|
|
+ borderTopLeftRadius: px2vmin(32),
|
|
|
+ borderTopRightRadius: px2vmin(32),
|
|
|
+ boxSizing: 'border-box',
|
|
|
+ overflow: 'hidden',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: 0,
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ height: px2vmin(44),
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ pointerEvents: 'none',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ width: px2vmin(32),
|
|
|
+ height: px2vmin(4),
|
|
|
+ backgroundColor: '#79747E',
|
|
|
+ borderRadius: px2vmin(4),
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(56),
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ width: px2vmin(80),
|
|
|
+ height: px2vmin(80),
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <img
|
|
|
+ src={getOssUrl(activeFurnitureModalItem.image_name)}
|
|
|
+ alt={getCatalogItemDisplayName(activeFurnitureModalItem)}
|
|
|
+ draggable={false}
|
|
|
+ style={{
|
|
|
+ width: '100%',
|
|
|
+ height: '100%',
|
|
|
+ objectFit: 'contain',
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(146),
|
|
|
+ left: 0,
|
|
|
+ right: 0,
|
|
|
+ margin: 0,
|
|
|
+ textAlign: 'center',
|
|
|
+ fontSize: px2vmin(22),
|
|
|
+ lineHeight: px2vmin(30),
|
|
|
+ color: '#FFFFFF',
|
|
|
+ fontWeight: 400,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {getCatalogItemDisplayName(activeFurnitureModalItem)}
|
|
|
+ </p>
|
|
|
+
|
|
|
+ {Number(activeFurnitureModalItem.has_special_scene_trigger || 0) > 0 ? (
|
|
|
+ <p
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(184),
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ width: px2vmin(312),
|
|
|
+ margin: 0,
|
|
|
+ textAlign: 'center',
|
|
|
+ color: '#FFFFFF',
|
|
|
+ fontSize: px2vmin(14),
|
|
|
+ lineHeight: px2vmin(20),
|
|
|
+ opacity: 0.9,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {tProfile('lumiFurnitureScenarioHint')}
|
|
|
+ </p>
|
|
|
+ ) : null}
|
|
|
+
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ position: 'absolute',
|
|
|
+ top: px2vmin(241),
|
|
|
+ left: '50%',
|
|
|
+ transform: 'translateX(-50%)',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ columnGap: px2vmin(20),
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => {
|
|
|
+ handleFurnitureModalConfirm();
|
|
|
+ }}
|
|
|
+ disabled={furnitureSubmitting}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(146),
|
|
|
+ height: px2vmin(56),
|
|
|
+ padding: 0,
|
|
|
+ border: 'none',
|
|
|
+ borderRadius: px2vmin(12),
|
|
|
+ background: `url(${primaryDefaultButton}) center/cover no-repeat`,
|
|
|
+ cursor: 'pointer',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ color: '#fff',
|
|
|
+ fontSize: px2vmin(16),
|
|
|
+ lineHeight: px2vmin(24),
|
|
|
+ fontWeight: 500,
|
|
|
+ opacity: furnitureSubmitting ? 0.7 : 1,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {furnitureModalMode === 'remove'
|
|
|
+ ? tProfile('lumiFurnitureConfirmRemoval')
|
|
|
+ : tProfile('lumiFurnitureEquip')}
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={() => setActiveFurnitureModalItem(null)}
|
|
|
+ disabled={furnitureSubmitting}
|
|
|
+ style={{
|
|
|
+ width: px2vmin(146),
|
|
|
+ height: px2vmin(56),
|
|
|
+ padding: 0,
|
|
|
+ border: 'none',
|
|
|
+ borderRadius: px2vmin(12),
|
|
|
+ background: `url(${secondaryDefaultButton}) center/cover no-repeat`,
|
|
|
+ cursor: 'pointer',
|
|
|
+ display: 'flex',
|
|
|
+ alignItems: 'center',
|
|
|
+ justifyContent: 'center',
|
|
|
+ color: '#fff',
|
|
|
+ fontSize: px2vmin(16),
|
|
|
+ lineHeight: px2vmin(24),
|
|
|
+ fontWeight: 500,
|
|
|
+ opacity: furnitureSubmitting ? 0.7 : 1,
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {tProfile('lumiFurnitureCancel')}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : null}
|
|
|
+ </>
|
|
|
+ );
|
|
|
+};
|