mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
🆙 Wheel of prizes !
This commit is contained in:
@@ -639,6 +639,8 @@
|
|||||||
'wheel.free.today': 'Je hebt vandaag %count% gratis draaibeurten!',
|
'wheel.free.today': 'Je hebt vandaag %count% gratis draaibeurten!',
|
||||||
'wheel.extra': 'Extra draaibeurten: %count%',
|
'wheel.extra': 'Extra draaibeurten: %count%',
|
||||||
'wheel.spin': 'DRAAIEN',
|
'wheel.spin': 'DRAAIEN',
|
||||||
|
'wheel.settings': 'Settings',
|
||||||
|
'wheel.settings.title': 'Rad van Fortuin Settings',
|
||||||
'wheel.buy': 'Draaibeurt kopen',
|
'wheel.buy': 'Draaibeurt kopen',
|
||||||
'wheel.winners': 'Laatste winnaars',
|
'wheel.winners': 'Laatste winnaars',
|
||||||
'wheel.winners.empty': 'Nog geen winnaars',
|
'wheel.winners.empty': 'Nog geen winnaars',
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { IWheelAdminPrize, IWheelAdminPrizeEdit } from '@nitrots/nitro-renderer';
|
||||||
|
import { FC, useEffect, useState } from 'react';
|
||||||
|
import { LocalizeText } from '../../api';
|
||||||
|
import { Column, Flex, Text } from '../../common';
|
||||||
|
import { useFortuneWheel } from '../../hooks';
|
||||||
|
import { NitroCard } from '../../layout';
|
||||||
|
|
||||||
|
interface EditRow
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
num: number;
|
||||||
|
weight: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryDef
|
||||||
|
{
|
||||||
|
key: string;
|
||||||
|
labelKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES: CategoryDef[] = [
|
||||||
|
{ key: 'item', labelKey: 'rarevalues.editor.cat.item' },
|
||||||
|
{ key: 'diamonds', labelKey: 'achievements.activitypoint.5' },
|
||||||
|
{ key: 'duckets', labelKey: 'achievements.activitypoint.0' },
|
||||||
|
{ key: 'credits', labelKey: 'credits' },
|
||||||
|
{ key: 'spins', labelKey: 'rarevalues.editor.cat.spin' },
|
||||||
|
{ key: 'nothing', labelKey: 'rarevalues.editor.cat.nothing' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const prizeToCategory = (prize: IWheelAdminPrize): string =>
|
||||||
|
{
|
||||||
|
switch(prize.type)
|
||||||
|
{
|
||||||
|
case 'item': return 'item';
|
||||||
|
case 'points': return (prize.pointsType === 5) ? 'diamonds' : 'duckets';
|
||||||
|
case 'credits': return 'credits';
|
||||||
|
case 'spin': return 'spins';
|
||||||
|
default: return 'nothing';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prizeToNum = (prize: IWheelAdminPrize): number =>
|
||||||
|
(prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount;
|
||||||
|
|
||||||
|
const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit =>
|
||||||
|
{
|
||||||
|
const base = { id: row.id, weight: row.weight, label: row.label };
|
||||||
|
|
||||||
|
switch(row.category)
|
||||||
|
{
|
||||||
|
case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 };
|
||||||
|
case 'diamonds': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 };
|
||||||
|
case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 };
|
||||||
|
case 'credits': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 };
|
||||||
|
case 'spins': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 };
|
||||||
|
default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FortuneWheelSettingsViewProps
|
||||||
|
{
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ onClose }) =>
|
||||||
|
{
|
||||||
|
const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel();
|
||||||
|
const [ editRows, setEditRows ] = useState<EditRow[]>([]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(loadAdminPrizes) loadAdminPrizes();
|
||||||
|
}, [ loadAdminPrizes ]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
setEditRows(adminPrizes.map(prize => ({
|
||||||
|
id: prize.id,
|
||||||
|
category: prizeToCategory(prize),
|
||||||
|
num: prizeToNum(prize),
|
||||||
|
weight: prize.weight,
|
||||||
|
label: prize.label
|
||||||
|
})));
|
||||||
|
}, [ adminPrizes ]);
|
||||||
|
|
||||||
|
const updateRow = (id: number, patch: Partial<EditRow>) =>
|
||||||
|
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NitroCard className="w-[480px] h-[520px]" uniqueKey="fortune-wheel-settings">
|
||||||
|
<NitroCard.Header
|
||||||
|
headerText={ LocalizeText('wheel.settings.title') }
|
||||||
|
onCloseClick={ onClose } />
|
||||||
|
<NitroCard.Content>
|
||||||
|
<Column gap={ 1 } className="h-full p-1">
|
||||||
|
<Flex gap={ 1 } className="px-1 text-[11px] font-bold text-black/60">
|
||||||
|
<span className="w-28">{ LocalizeText('rarevalues.editor.type') }</span>
|
||||||
|
<span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span>
|
||||||
|
<span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span>
|
||||||
|
<span className="grow">{ LocalizeText('rarevalues.editor.label') }</span>
|
||||||
|
</Flex>
|
||||||
|
<Column gap={ 1 } overflow="auto" className="grow">
|
||||||
|
{ editRows.map(row => (
|
||||||
|
<Flex key={ row.id } alignItems="center" gap={ 1 } className="border-b border-black/10 pb-1">
|
||||||
|
<select
|
||||||
|
value={ row.category }
|
||||||
|
onChange={ event => updateRow(row.id, { category: event.target.value }) }
|
||||||
|
className="w-28 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]">
|
||||||
|
{ CATEGORIES.map(cat => (
|
||||||
|
<option key={ cat.key } value={ cat.key }>{ LocalizeText(cat.labelKey) }</option>
|
||||||
|
)) }
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ row.num }
|
||||||
|
disabled={ row.category === 'nothing' }
|
||||||
|
onChange={ event => updateRow(row.id, { num: parseInt(event.target.value) || 0 }) }
|
||||||
|
className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ row.weight }
|
||||||
|
onChange={ event => updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) }
|
||||||
|
className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ row.label }
|
||||||
|
onChange={ event => updateRow(row.id, { label: event.target.value }) }
|
||||||
|
className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
|
||||||
|
</Flex>
|
||||||
|
)) }
|
||||||
|
{ !editRows.length &&
|
||||||
|
<Text small className="text-black/50">{ LocalizeText('wheel.settings.empty') }</Text> }
|
||||||
|
</Column>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={ !editRows.length }
|
||||||
|
onClick={ () => saveAdminPrizes?.(editRows.map(rowToEdit)) }
|
||||||
|
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
|
||||||
|
{ LocalizeText('rarevalues.editor.save') }
|
||||||
|
</button>
|
||||||
|
</Column>
|
||||||
|
</NitroCard.Content>
|
||||||
|
</NitroCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,8 +2,9 @@ import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, Rem
|
|||||||
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { LocalizeText } from '../../api';
|
import { LocalizeText } from '../../api';
|
||||||
import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
|
import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
|
||||||
import { useFortuneWheel } from '../../hooks';
|
import { useFortuneWheel, useHasPermission } from '../../hooks';
|
||||||
import { NitroCard } from '../../layout';
|
import { NitroCard } from '../../layout';
|
||||||
|
import { FortuneWheelSettingsView } from './FortuneWheelSettingsView';
|
||||||
|
|
||||||
// Stock UI palette (white / light-blue / grey / black).
|
// Stock UI palette (white / light-blue / grey / black).
|
||||||
const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ];
|
const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ];
|
||||||
@@ -42,7 +43,9 @@ const renderPrizeIcon = (prize: IWheelPrize) =>
|
|||||||
export const FortuneWheelView: FC<{}> = () =>
|
export const FortuneWheelView: FC<{}> = () =>
|
||||||
{
|
{
|
||||||
const [ isVisible, setIsVisible ] = useState(false);
|
const [ isVisible, setIsVisible ] = useState(false);
|
||||||
|
const [ isSettingsOpen, setIsSettingsOpen ] = useState(false);
|
||||||
const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel();
|
const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel();
|
||||||
|
const canManage = useHasPermission('acc_wheeladmin');
|
||||||
const [ rotation, setRotation ] = useState(0);
|
const [ rotation, setRotation ] = useState(0);
|
||||||
const rotationRef = useRef(0);
|
const rotationRef = useRef(0);
|
||||||
const prizesRef = useRef<IWheelPrize[]>([]);
|
const prizesRef = useRef<IWheelPrize[]>([]);
|
||||||
@@ -164,6 +167,12 @@ export const FortuneWheelView: FC<{}> = () =>
|
|||||||
{ LocalizeText('wheel.buy') } { spinCost }
|
{ LocalizeText('wheel.buy') } { spinCost }
|
||||||
<LayoutCurrencyIcon type={ spinCostType } />
|
<LayoutCurrencyIcon type={ spinCostType } />
|
||||||
</button>
|
</button>
|
||||||
|
{ canManage &&
|
||||||
|
<button
|
||||||
|
onClick={ () => setIsSettingsOpen(true) }
|
||||||
|
className="cursor-pointer rounded bg-[#8a6b3a] px-3 py-2 font-bold text-white hover:bg-[#735730]">
|
||||||
|
{ LocalizeText('wheel.settings') }
|
||||||
|
</button> }
|
||||||
</Flex>
|
</Flex>
|
||||||
</Column>
|
</Column>
|
||||||
<Column gap={ 2 } className="min-w-[300px] grow rounded-lg border border-black/10 bg-black/5 p-3">
|
<Column gap={ 2 } className="min-w-[300px] grow rounded-lg border border-black/10 bg-black/5 p-3">
|
||||||
@@ -186,6 +195,8 @@ export const FortuneWheelView: FC<{}> = () =>
|
|||||||
</Column>
|
</Column>
|
||||||
</Flex>
|
</Flex>
|
||||||
</NitroCard.Content>
|
</NitroCard.Content>
|
||||||
|
{ canManage && isSettingsOpen &&
|
||||||
|
<FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> }
|
||||||
</NitroCard>
|
</NitroCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, IWheelAdminPrize, IWheelAdminPrizeEdit, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
import { FC, useEffect, useMemo, useState } from 'react';
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
import { LocalizeFormattedNumber, LocalizeText } from '../../api';
|
import { LocalizeFormattedNumber, LocalizeText } from '../../api';
|
||||||
import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
|
import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
|
||||||
import { useFortuneWheel, useHasPermission, useRareValues } from '../../hooks';
|
import { useRareValues } from '../../hooks';
|
||||||
import { NitroCard, NitroInput } from '../../layout';
|
import { NitroCard, NitroInput } from '../../layout';
|
||||||
|
|
||||||
interface RareValueRow
|
interface RareValueRow
|
||||||
@@ -13,63 +13,11 @@ interface RareValueRow
|
|||||||
value: IRareValue;
|
value: IRareValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditRow
|
|
||||||
{
|
|
||||||
id: number;
|
|
||||||
category: string;
|
|
||||||
num: number;
|
|
||||||
weight: number;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORIES: { key: string; label: string }[] = [
|
|
||||||
{ key: 'item', label: 'Raro (ID)' },
|
|
||||||
{ key: 'diamanti', label: 'Diamanti' },
|
|
||||||
{ key: 'duckets', label: 'Duckets' },
|
|
||||||
{ key: 'crediti', label: 'Crediti' },
|
|
||||||
{ key: 'giri', label: 'Giri extra' },
|
|
||||||
{ key: 'nulla', label: 'Nulla' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const prizeToCategory = (prize: IWheelAdminPrize): string =>
|
|
||||||
{
|
|
||||||
switch(prize.type)
|
|
||||||
{
|
|
||||||
case 'item': return 'item';
|
|
||||||
case 'points': return (prize.pointsType === 5) ? 'diamanti' : 'duckets';
|
|
||||||
case 'credits': return 'crediti';
|
|
||||||
case 'spin': return 'giri';
|
|
||||||
default: return 'nulla';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const prizeToNum = (prize: IWheelAdminPrize): number =>
|
|
||||||
(prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount;
|
|
||||||
|
|
||||||
const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit =>
|
|
||||||
{
|
|
||||||
const base = { id: row.id, weight: row.weight, label: row.label };
|
|
||||||
|
|
||||||
switch(row.category)
|
|
||||||
{
|
|
||||||
case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 };
|
|
||||||
case 'diamanti': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 };
|
|
||||||
case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 };
|
|
||||||
case 'crediti': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 };
|
|
||||||
case 'giri': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 };
|
|
||||||
default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RareValuesView: FC<{}> = () =>
|
export const RareValuesView: FC<{}> = () =>
|
||||||
{
|
{
|
||||||
const [ isVisible, setIsVisible ] = useState(false);
|
const [ isVisible, setIsVisible ] = useState(false);
|
||||||
const [ tab, setTab ] = useState<'values' | 'editor'>('values');
|
|
||||||
const [ searchValue, setSearchValue ] = useState('');
|
const [ searchValue, setSearchValue ] = useState('');
|
||||||
const { values = null, loaded = false } = useRareValues();
|
const { values = null, loaded = false } = useRareValues();
|
||||||
const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel();
|
|
||||||
const canEdit = useHasPermission('acc_supporttool');
|
|
||||||
const [ editRows, setEditRows ] = useState<EditRow[]>([]);
|
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
@@ -94,16 +42,6 @@ export const RareValuesView: FC<{}> = () =>
|
|||||||
return () => RemoveLinkEventTracker(linkTracker);
|
return () => RemoveLinkEventTracker(linkTracker);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() =>
|
|
||||||
{
|
|
||||||
if(isVisible && (tab === 'editor') && canEdit && loadAdminPrizes) loadAdminPrizes();
|
|
||||||
}, [ isVisible, tab, canEdit, loadAdminPrizes ]);
|
|
||||||
|
|
||||||
useEffect(() =>
|
|
||||||
{
|
|
||||||
setEditRows(adminPrizes.map(prize => ({ id: prize.id, category: prizeToCategory(prize), num: prizeToNum(prize), weight: prize.weight, label: prize.label })));
|
|
||||||
}, [ adminPrizes ]);
|
|
||||||
|
|
||||||
const rows = useMemo<RareValueRow[]>(() =>
|
const rows = useMemo<RareValueRow[]>(() =>
|
||||||
{
|
{
|
||||||
if(!values) return [];
|
if(!values) return [];
|
||||||
@@ -143,91 +81,34 @@ export const RareValuesView: FC<{}> = () =>
|
|||||||
|
|
||||||
if(!isVisible) return null;
|
if(!isVisible) return null;
|
||||||
|
|
||||||
const updateRow = (id: number, patch: Partial<EditRow>) =>
|
|
||||||
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NitroCard className="w-[420px] h-[480px]" uniqueKey="rare-values">
|
<NitroCard className="w-[420px] h-[480px]" uniqueKey="rare-values">
|
||||||
<NitroCard.Header
|
<NitroCard.Header
|
||||||
headerText={ LocalizeText('rarevalues.title') }
|
headerText={ LocalizeText('rarevalues.title') }
|
||||||
onCloseClick={ () => setIsVisible(false) } />
|
onCloseClick={ () => setIsVisible(false) } />
|
||||||
{ canEdit &&
|
|
||||||
<NitroCard.Tabs>
|
|
||||||
<NitroCard.TabItem isActive={ tab === 'values' } onClick={ () => setTab('values') }>
|
|
||||||
<Text>{ LocalizeText('rarevalues.title') }</Text>
|
|
||||||
</NitroCard.TabItem>
|
|
||||||
<NitroCard.TabItem isActive={ tab === 'editor' } onClick={ () => setTab('editor') }>
|
|
||||||
<Text>{ LocalizeText('rarevalues.editor.tab') }</Text>
|
|
||||||
</NitroCard.TabItem>
|
|
||||||
</NitroCard.Tabs> }
|
|
||||||
<NitroCard.Content>
|
<NitroCard.Content>
|
||||||
{ (tab === 'values' || !canEdit) &&
|
<Column gap={ 2 } className="h-full p-1">
|
||||||
<Column gap={ 2 } className="h-full p-1">
|
<NitroInput
|
||||||
<NitroInput
|
placeholder={ LocalizeText('generic.search') }
|
||||||
placeholder={ LocalizeText('generic.search') }
|
value={ searchValue }
|
||||||
value={ searchValue }
|
onChange={ event => setSearchValue(event.target.value) } />
|
||||||
onChange={ event => setSearchValue(event.target.value) } />
|
<Column gap={ 0 } overflow="auto" className="grow">
|
||||||
<Column gap={ 0 } overflow="auto" className="grow">
|
{ !loaded &&
|
||||||
{ !loaded &&
|
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.loading') }</Text> }
|
||||||
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.loading') }</Text> }
|
{ (loaded && !filtered.length) &&
|
||||||
{ (loaded && !filtered.length) &&
|
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.empty') }</Text> }
|
||||||
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.empty') }</Text> }
|
{ filtered.map(row => (
|
||||||
{ filtered.map(row => (
|
<Flex key={ row.spriteId } alignItems="center" gap={ 2 } className="border-b border-black/10 py-1.5 hover:bg-black/5">
|
||||||
<Flex key={ row.spriteId } alignItems="center" gap={ 2 } className="border-b border-black/10 py-1.5 hover:bg-black/5">
|
<LayoutImage imageUrl={ row.iconUrl } className="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" />
|
||||||
<LayoutImage imageUrl={ row.iconUrl } className="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" />
|
<Text truncate className="grow text-[#1f2d34]">{ row.name }</Text>
|
||||||
<Text truncate className="grow text-[#1f2d34]">{ row.name }</Text>
|
<Flex alignItems="center" gap={ 1 } className="shrink-0">
|
||||||
<Flex alignItems="center" gap={ 1 } className="shrink-0">
|
<Text bold textEnd className="text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
|
||||||
<Text bold textEnd className="text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
|
<LayoutCurrencyIcon type={ row.value.pointsType } />
|
||||||
<LayoutCurrencyIcon type={ row.value.pointsType } />
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
)) }
|
</Flex>
|
||||||
</Column>
|
)) }
|
||||||
</Column> }
|
</Column>
|
||||||
|
</Column>
|
||||||
{ (tab === 'editor' && canEdit) &&
|
|
||||||
<Column gap={ 1 } className="h-full p-1">
|
|
||||||
<Flex gap={ 1 } className="px-1 text-[11px] font-bold text-black/60">
|
|
||||||
<span className="w-28">{ LocalizeText('rarevalues.editor.type') }</span>
|
|
||||||
<span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span>
|
|
||||||
<span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span>
|
|
||||||
<span className="grow">{ LocalizeText('rarevalues.editor.label') }</span>
|
|
||||||
</Flex>
|
|
||||||
<Column gap={ 1 } overflow="auto" className="grow">
|
|
||||||
{ editRows.map(row => (
|
|
||||||
<Flex key={ row.id } alignItems="center" gap={ 1 } className="border-b border-black/10 pb-1">
|
|
||||||
<select
|
|
||||||
value={ row.category }
|
|
||||||
onChange={ event => updateRow(row.id, { category: event.target.value }) }
|
|
||||||
className="w-28 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]">
|
|
||||||
{ CATEGORIES.map(cat => <option key={ cat.key } value={ cat.key }>{ cat.label }</option>) }
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={ row.num }
|
|
||||||
disabled={ row.category === 'nulla' }
|
|
||||||
onChange={ event => updateRow(row.id, { num: parseInt(event.target.value) || 0 }) }
|
|
||||||
className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" />
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={ row.weight }
|
|
||||||
onChange={ event => updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) }
|
|
||||||
className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ row.label }
|
|
||||||
onChange={ event => updateRow(row.id, { label: event.target.value }) }
|
|
||||||
className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
|
|
||||||
</Flex>
|
|
||||||
)) }
|
|
||||||
</Column>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={ () => saveAdminPrizes?.(editRows.map(rowToEdit)) }
|
|
||||||
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3]">
|
|
||||||
{ LocalizeText('rarevalues.editor.save') }
|
|
||||||
</button>
|
|
||||||
</Column> }
|
|
||||||
</NitroCard.Content>
|
</NitroCard.Content>
|
||||||
</NitroCard>
|
</NitroCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { IWheelAdminPrize, IWheelAdminPrizeEdit } from '@nitrots/nitro-renderer';
|
||||||
|
import { FC, useEffect, useState } from 'react';
|
||||||
|
import { LocalizeText } from '../../api';
|
||||||
|
import { Column, Flex, Text } from '../../common';
|
||||||
|
import { useFortuneWheel } from '../../hooks';
|
||||||
|
import { NitroCard } from '../../layout';
|
||||||
|
|
||||||
|
interface EditRow
|
||||||
|
{
|
||||||
|
id: number;
|
||||||
|
category: string;
|
||||||
|
num: number;
|
||||||
|
weight: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryDef
|
||||||
|
{
|
||||||
|
key: string;
|
||||||
|
labelKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES: CategoryDef[] = [
|
||||||
|
{ key: 'item', labelKey: 'rarevalues.editor.cat.item' },
|
||||||
|
{ key: 'diamanti', labelKey: 'achievements.activitypoint.5' },
|
||||||
|
{ key: 'duckets', labelKey: 'achievements.activitypoint.0' },
|
||||||
|
{ key: 'crediti', labelKey: 'credits' },
|
||||||
|
{ key: 'giri', labelKey: 'rarevalues.editor.cat.spin' },
|
||||||
|
{ key: 'nulla', labelKey: 'rarevalues.editor.cat.nothing' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const prizeToCategory = (prize: IWheelAdminPrize): string =>
|
||||||
|
{
|
||||||
|
switch(prize.type)
|
||||||
|
{
|
||||||
|
case 'item': return 'item';
|
||||||
|
case 'points': return (prize.pointsType === 5) ? 'diamanti' : 'duckets';
|
||||||
|
case 'credits': return 'crediti';
|
||||||
|
case 'spin': return 'giri';
|
||||||
|
default: return 'nulla';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const prizeToNum = (prize: IWheelAdminPrize): number =>
|
||||||
|
(prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount;
|
||||||
|
|
||||||
|
const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit =>
|
||||||
|
{
|
||||||
|
const base = { id: row.id, weight: row.weight, label: row.label };
|
||||||
|
|
||||||
|
switch(row.category)
|
||||||
|
{
|
||||||
|
case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 };
|
||||||
|
case 'diamanti': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 };
|
||||||
|
case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 };
|
||||||
|
case 'crediti': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 };
|
||||||
|
case 'giri': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 };
|
||||||
|
default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FortuneWheelSettingsViewProps
|
||||||
|
{
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FortuneWheelSettingsView: FC<FortuneWheelSettingsViewProps> = ({ onClose }) =>
|
||||||
|
{
|
||||||
|
const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel();
|
||||||
|
const [ editRows, setEditRows ] = useState<EditRow[]>([]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(loadAdminPrizes) loadAdminPrizes();
|
||||||
|
}, [ loadAdminPrizes ]);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
setEditRows(adminPrizes.map(prize => ({
|
||||||
|
id: prize.id,
|
||||||
|
category: prizeToCategory(prize),
|
||||||
|
num: prizeToNum(prize),
|
||||||
|
weight: prize.weight,
|
||||||
|
label: prize.label
|
||||||
|
})));
|
||||||
|
}, [ adminPrizes ]);
|
||||||
|
|
||||||
|
const updateRow = (id: number, patch: Partial<EditRow>) =>
|
||||||
|
setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NitroCard className="w-[480px] h-[520px]" uniqueKey="fortune-wheel-settings">
|
||||||
|
<NitroCard.Header
|
||||||
|
headerText={ LocalizeText('wheel.settings.title') }
|
||||||
|
onCloseClick={ onClose } />
|
||||||
|
<NitroCard.Content>
|
||||||
|
<Column gap={ 1 } className="h-full p-1">
|
||||||
|
<Flex gap={ 1 } className="px-1 text-[11px] font-bold text-black/60">
|
||||||
|
<span className="w-28">{ LocalizeText('rarevalues.editor.type') }</span>
|
||||||
|
<span className="w-16">{ LocalizeText('rarevalues.editor.value') }</span>
|
||||||
|
<span className="w-12">{ LocalizeText('rarevalues.editor.weight') }</span>
|
||||||
|
<span className="grow">{ LocalizeText('rarevalues.editor.label') }</span>
|
||||||
|
</Flex>
|
||||||
|
<Column gap={ 1 } overflow="auto" className="grow">
|
||||||
|
{ editRows.map(row => (
|
||||||
|
<Flex key={ row.id } alignItems="center" gap={ 1 } className="border-b border-black/10 pb-1">
|
||||||
|
<select
|
||||||
|
value={ row.category }
|
||||||
|
onChange={ event => updateRow(row.id, { category: event.target.value }) }
|
||||||
|
className="w-28 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]">
|
||||||
|
{ CATEGORIES.map(cat => (
|
||||||
|
<option key={ cat.key } value={ cat.key }>{ LocalizeText(cat.labelKey) }</option>
|
||||||
|
)) }
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ row.num }
|
||||||
|
disabled={ row.category === 'nulla' }
|
||||||
|
onChange={ event => updateRow(row.id, { num: parseInt(event.target.value) || 0 }) }
|
||||||
|
className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ row.weight }
|
||||||
|
onChange={ event => updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) }
|
||||||
|
className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ row.label }
|
||||||
|
onChange={ event => updateRow(row.id, { label: event.target.value }) }
|
||||||
|
className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" />
|
||||||
|
</Flex>
|
||||||
|
)) }
|
||||||
|
{ !editRows.length &&
|
||||||
|
<Text small className="text-black/50">{ LocalizeText('wheel.settings.empty') }</Text> }
|
||||||
|
</Column>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={ !editRows.length }
|
||||||
|
onClick={ () => saveAdminPrizes?.(editRows.map(rowToEdit)) }
|
||||||
|
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
|
||||||
|
{ LocalizeText('rarevalues.editor.save') }
|
||||||
|
</button>
|
||||||
|
</Column>
|
||||||
|
</NitroCard.Content>
|
||||||
|
</NitroCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
|
import { FC, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { LocalizeText } from '../../api';
|
||||||
|
import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
|
||||||
|
import { useFortuneWheel, useHasPermission } from '../../hooks';
|
||||||
|
import { NitroCard } from '../../layout';
|
||||||
|
import { FortuneWheelSettingsView } from './FortuneWheelSettingsView';
|
||||||
|
|
||||||
|
// Stock UI palette (white / light-blue / grey / black).
|
||||||
|
const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ];
|
||||||
|
const RIM = '#4c606c';
|
||||||
|
const WHEEL_SIZE = 420;
|
||||||
|
const ICON_RADIUS = 150;
|
||||||
|
const FULL_TURNS = 5;
|
||||||
|
|
||||||
|
const renderPrizeIcon = (prize: IWheelPrize) =>
|
||||||
|
{
|
||||||
|
switch(prize.type)
|
||||||
|
{
|
||||||
|
case 'item':
|
||||||
|
return <LayoutImage imageUrl={ GetRoomEngine().getFurnitureFloorIconUrl(prize.spriteId) } className="h-9 w-9 bg-contain bg-center bg-no-repeat" />;
|
||||||
|
case 'badge':
|
||||||
|
return <LayoutBadgeImageView badgeCode={ prize.badgeCode } />;
|
||||||
|
case 'credits':
|
||||||
|
return (
|
||||||
|
<Column alignItems="center" gap={ 0 }>
|
||||||
|
<LayoutCurrencyIcon type={ -1 } />
|
||||||
|
<span className="text-[10px] font-bold text-[#2a3a42]">{ prize.amount }</span>
|
||||||
|
</Column>);
|
||||||
|
case 'points':
|
||||||
|
return (
|
||||||
|
<Column alignItems="center" gap={ 0 }>
|
||||||
|
<LayoutCurrencyIcon type={ prize.pointsType } />
|
||||||
|
<span className="text-[10px] font-bold text-[#2a3a42]">{ prize.amount }</span>
|
||||||
|
</Column>);
|
||||||
|
case 'spin':
|
||||||
|
return <span className="text-xs font-bold text-[#2a3a42]">+{ prize.amount }</span>;
|
||||||
|
default:
|
||||||
|
return <span className="text-xs font-bold text-[#2a3a42]/60">—</span>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FortuneWheelView: FC<{}> = () =>
|
||||||
|
{
|
||||||
|
const [ isVisible, setIsVisible ] = useState(false);
|
||||||
|
const [ isSettingsOpen, setIsSettingsOpen ] = useState(false);
|
||||||
|
const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel();
|
||||||
|
const canManage = useHasPermission('acc_wheeladmin');
|
||||||
|
const [ rotation, setRotation ] = useState(0);
|
||||||
|
const rotationRef = useRef(0);
|
||||||
|
const prizesRef = useRef<IWheelPrize[]>([]);
|
||||||
|
prizesRef.current = prizes;
|
||||||
|
|
||||||
|
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(prev => !prev); return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
eventUrlPrefix: 'fortune-wheel/',
|
||||||
|
};
|
||||||
|
|
||||||
|
AddLinkEventTracker(linkTracker);
|
||||||
|
|
||||||
|
return () => RemoveLinkEventTracker(linkTracker);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(isVisible) open();
|
||||||
|
}, [ isVisible, open ]);
|
||||||
|
|
||||||
|
// Drive the spin animation when the server reports the winning slice.
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(pendingPrizeId < 0) return;
|
||||||
|
|
||||||
|
const list = prizesRef.current;
|
||||||
|
const idx = list.findIndex(prize => prize.id === pendingPrizeId);
|
||||||
|
|
||||||
|
if(!list.length || (idx < 0))
|
||||||
|
{
|
||||||
|
finishSpin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliceAngle = 360 / list.length;
|
||||||
|
const centerAngle = ((idx + 0.5) * sliceAngle);
|
||||||
|
const current = rotationRef.current;
|
||||||
|
const target = (current - (current % 360)) + (FULL_TURNS * 360) + (360 - centerAngle);
|
||||||
|
|
||||||
|
rotationRef.current = target;
|
||||||
|
setRotation(target);
|
||||||
|
}, [ pendingPrizeId, finishSpin ]);
|
||||||
|
|
||||||
|
const sliceAngle = prizes.length ? (360 / prizes.length) : 0;
|
||||||
|
|
||||||
|
const background = useMemo(() =>
|
||||||
|
{
|
||||||
|
if(!prizes.length) return SLICE_COLORS[0];
|
||||||
|
|
||||||
|
const stops = prizes.map((_, i) => `${ SLICE_COLORS[i % 2] } ${ i * sliceAngle }deg ${ (i + 1) * sliceAngle }deg`).join(', ');
|
||||||
|
return `conic-gradient(${ stops })`;
|
||||||
|
}, [ prizes, sliceAngle ]);
|
||||||
|
|
||||||
|
if(!isVisible) return null;
|
||||||
|
|
||||||
|
const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NitroCard className="w-[800px] max-w-[96vw]" uniqueKey="fortune-wheel">
|
||||||
|
<NitroCard.Header headerText={ LocalizeText('wheel.title') } onCloseClick={ () => setIsVisible(false) } />
|
||||||
|
<NitroCard.Content>
|
||||||
|
<Flex gap={ 3 }>
|
||||||
|
<Column alignItems="center" gap={ 2 } className="shrink-0">
|
||||||
|
<div className="relative" style={ { width: WHEEL_SIZE, height: WHEEL_SIZE } }>
|
||||||
|
<div
|
||||||
|
className="absolute left-1/2 -top-2 z-20 -translate-x-1/2 drop-shadow-[0_2px_2px_rgba(0,0,0,0.25)]"
|
||||||
|
style={ { width: 0, height: 0, borderLeft: '14px solid transparent', borderRight: '14px solid transparent', borderTop: `28px solid ${ RIM }` } } />
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-full shadow-[0_0_0_4px_rgba(0,0,0,0.12),inset_0_0_18px_rgba(0,0,0,0.1)]"
|
||||||
|
style={ { background, border: `8px solid ${ RIM }`, transform: `rotate(${ rotation }deg)`, transition: isSpinning ? 'transform 4.5s cubic-bezier(0.15,0.85,0.25,1)' : 'none' } }
|
||||||
|
onTransitionEnd={ () => { if(isSpinning) finishSpin(); } }>
|
||||||
|
{ prizes.map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={ `divider-${ i }` }
|
||||||
|
className="absolute bottom-1/2 left-1/2 origin-bottom"
|
||||||
|
style={ { width: '2px', height: `${ WHEEL_SIZE / 2 }px`, transform: `translateX(-1px) rotate(${ i * sliceAngle }deg)`, background: 'rgba(76,96,108,0.3)' } } />
|
||||||
|
)) }
|
||||||
|
{ prizes.map((prize, i) =>
|
||||||
|
{
|
||||||
|
const centerAngle = ((i + 0.5) * sliceAngle);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ prize.id }
|
||||||
|
className="absolute left-1/2 top-1/2"
|
||||||
|
style={ { transform: `rotate(${ centerAngle }deg) translateY(-${ ICON_RADIUS }px) rotate(-${ centerAngle }deg)` } }>
|
||||||
|
<div className="-translate-x-1/2 -translate-y-1/2">
|
||||||
|
{ renderPrizeIcon(prize) }
|
||||||
|
</div>
|
||||||
|
</div>);
|
||||||
|
}) }
|
||||||
|
</div>
|
||||||
|
<div className="absolute left-1/2 top-1/2 z-10 h-14 w-14 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[#eef2f5] shadow-[0_0_8px_rgba(0,0,0,0.25)]" style={ { border: `4px solid ${ RIM }` } } />
|
||||||
|
</div>
|
||||||
|
<Text bold className="text-[#2f6f95]">{ LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) }</Text>
|
||||||
|
<Text small className="text-[#33424c]">{ LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) }</Text>
|
||||||
|
<Flex gap={ 2 } alignItems="center">
|
||||||
|
<button
|
||||||
|
disabled={ !canSpin }
|
||||||
|
onClick={ () => spin() }
|
||||||
|
className="cursor-pointer rounded bg-[#3a7bb5] px-4 py-2 font-bold text-white hover:bg-[#336ea3] disabled:cursor-default disabled:opacity-40">
|
||||||
|
{ LocalizeText('wheel.spin') }
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={ () => buySpin() }
|
||||||
|
className="flex cursor-pointer items-center gap-1 rounded bg-[#6b7884] px-3 py-2 text-white hover:bg-[#5e6a75]">
|
||||||
|
{ LocalizeText('wheel.buy') } { spinCost }
|
||||||
|
<LayoutCurrencyIcon type={ spinCostType } />
|
||||||
|
</button>
|
||||||
|
{ canManage &&
|
||||||
|
<button
|
||||||
|
onClick={ () => setIsSettingsOpen(true) }
|
||||||
|
className="cursor-pointer rounded bg-[#8a6b3a] px-3 py-2 font-bold text-white hover:bg-[#735730]">
|
||||||
|
{ LocalizeText('wheel.settings') }
|
||||||
|
</button> }
|
||||||
|
</Flex>
|
||||||
|
</Column>
|
||||||
|
<Column gap={ 2 } className="min-w-[300px] grow rounded-lg border border-black/10 bg-black/5 p-3">
|
||||||
|
<Text bold className="text-base text-[#33424c]">{ LocalizeText('wheel.winners') }</Text>
|
||||||
|
<Column gap={ 1 } overflow="auto" className="h-[440px]">
|
||||||
|
{ recentWins.map((win, i) => (
|
||||||
|
<Flex key={ i } alignItems="center" gap={ 2 } className="rounded border-b border-black/10 py-1.5 hover:bg-black/5">
|
||||||
|
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded bg-black/5">
|
||||||
|
<LayoutAvatarImageView figure={ win.look } headOnly direction={ 2 } style={ { backgroundSize: 'auto', backgroundPosition: '-22px -32px' } } />
|
||||||
|
</div>
|
||||||
|
<Column gap={ 0 } className="min-w-0">
|
||||||
|
<Text bold truncate className="text-[#1f2d34]">{ win.username }</Text>
|
||||||
|
<Text small truncate className="text-[#2f6f95]">{ win.prizeLabel }</Text>
|
||||||
|
</Column>
|
||||||
|
</Flex>
|
||||||
|
)) }
|
||||||
|
{ !recentWins.length &&
|
||||||
|
<Text small className="text-black/50">{ LocalizeText('wheel.winners.empty') }</Text> }
|
||||||
|
</Column>
|
||||||
|
</Column>
|
||||||
|
</Flex>
|
||||||
|
</NitroCard.Content>
|
||||||
|
{ canManage && isSettingsOpen &&
|
||||||
|
<FortuneWheelSettingsView onClose={ () => setIsSettingsOpen(false) } /> }
|
||||||
|
</NitroCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
|
||||||
|
import { FC, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { LocalizeFormattedNumber, LocalizeText } from '../../api';
|
||||||
|
import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common';
|
||||||
|
import { useRareValues } from '../../hooks';
|
||||||
|
import { NitroCard, NitroInput } from '../../layout';
|
||||||
|
|
||||||
|
interface RareValueRow
|
||||||
|
{
|
||||||
|
spriteId: number;
|
||||||
|
name: string;
|
||||||
|
iconUrl: string;
|
||||||
|
value: IRareValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RareValuesView: FC<{}> = () =>
|
||||||
|
{
|
||||||
|
const [ isVisible, setIsVisible ] = useState(false);
|
||||||
|
const [ searchValue, setSearchValue ] = useState('');
|
||||||
|
const { values = null, loaded = false } = useRareValues();
|
||||||
|
|
||||||
|
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(prev => !prev); return;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
eventUrlPrefix: 'rare-values/',
|
||||||
|
};
|
||||||
|
|
||||||
|
AddLinkEventTracker(linkTracker);
|
||||||
|
|
||||||
|
return () => RemoveLinkEventTracker(linkTracker);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rows = useMemo<RareValueRow[]>(() =>
|
||||||
|
{
|
||||||
|
if(!values) return [];
|
||||||
|
|
||||||
|
const list: RareValueRow[] = [];
|
||||||
|
|
||||||
|
values.forEach((value, spriteId) =>
|
||||||
|
{
|
||||||
|
if(value.points <= 0) return;
|
||||||
|
|
||||||
|
const floorData = GetSessionDataManager().getFloorItemData(spriteId);
|
||||||
|
const wallData = floorData ? null : GetSessionDataManager().getWallItemData(spriteId);
|
||||||
|
const data = (floorData ?? wallData);
|
||||||
|
|
||||||
|
if(!data) return;
|
||||||
|
|
||||||
|
const iconUrl = (floorData
|
||||||
|
? GetRoomEngine().getFurnitureFloorIconUrl(spriteId)
|
||||||
|
: GetRoomEngine().getFurnitureWallIconUrl(spriteId));
|
||||||
|
|
||||||
|
list.push({ spriteId, name: (data.name || data.className || `#${ spriteId }`), iconUrl, value });
|
||||||
|
});
|
||||||
|
|
||||||
|
list.sort((a, b) => (b.value.points - a.value.points));
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [ values ]);
|
||||||
|
|
||||||
|
const filtered = useMemo<RareValueRow[]>(() =>
|
||||||
|
{
|
||||||
|
const query = searchValue.trim().toLocaleLowerCase();
|
||||||
|
|
||||||
|
if(!query) return rows;
|
||||||
|
|
||||||
|
return rows.filter(row => row.name.toLocaleLowerCase().includes(query));
|
||||||
|
}, [ rows, searchValue ]);
|
||||||
|
|
||||||
|
if(!isVisible) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NitroCard className="w-[420px] h-[480px]" uniqueKey="rare-values">
|
||||||
|
<NitroCard.Header
|
||||||
|
headerText={ LocalizeText('rarevalues.title') }
|
||||||
|
onCloseClick={ () => setIsVisible(false) } />
|
||||||
|
<NitroCard.Content>
|
||||||
|
<Column gap={ 2 } className="h-full p-1">
|
||||||
|
<NitroInput
|
||||||
|
placeholder={ LocalizeText('generic.search') }
|
||||||
|
value={ searchValue }
|
||||||
|
onChange={ event => setSearchValue(event.target.value) } />
|
||||||
|
<Column gap={ 0 } overflow="auto" className="grow">
|
||||||
|
{ !loaded &&
|
||||||
|
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.loading') }</Text> }
|
||||||
|
{ (loaded && !filtered.length) &&
|
||||||
|
<Text center className="mt-2 text-black/60">{ LocalizeText('rarevalues.empty') }</Text> }
|
||||||
|
{ filtered.map(row => (
|
||||||
|
<Flex key={ row.spriteId } alignItems="center" gap={ 2 } className="border-b border-black/10 py-1.5 hover:bg-black/5">
|
||||||
|
<LayoutImage imageUrl={ row.iconUrl } className="h-10 w-10 shrink-0 bg-contain bg-center bg-no-repeat" />
|
||||||
|
<Text truncate className="grow text-[#1f2d34]">{ row.name }</Text>
|
||||||
|
<Flex alignItems="center" gap={ 1 } className="shrink-0">
|
||||||
|
<Text bold textEnd className="text-[#2f6f95]">{ LocalizeFormattedNumber(row.value.points) }</Text>
|
||||||
|
<LayoutCurrencyIcon type={ row.value.pointsType } />
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
)) }
|
||||||
|
</Column>
|
||||||
|
</Column>
|
||||||
|
</NitroCard.Content>
|
||||||
|
</NitroCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,36 +1,35 @@
|
|||||||
import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer';
|
import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { CommandDefinition } from '../../../api';
|
import { CommandDefinition, LocalizeText } from '../../../api';
|
||||||
import { createNitroStore } from '../../../state/createNitroStore';
|
import { createNitroStore } from '../../../state/createNitroStore';
|
||||||
import { useMessageEvent } from '../../events';
|
import { useMessageEvent } from '../../events';
|
||||||
|
|
||||||
// Client-only commands are static; safe to keep at module scope.
|
const CLIENT_COMMANDS: { key: string; descriptionKey: string }[] = [
|
||||||
const CLIENT_COMMANDS: CommandDefinition[] = [
|
// Room effects
|
||||||
// Effetti stanza
|
{ key: 'shake', descriptionKey: 'chatcmd.client.shake' },
|
||||||
{ key: 'shake', description: 'Scuoti la stanza' },
|
{ key: 'rotate', descriptionKey: 'chatcmd.client.rotate' },
|
||||||
{ key: 'rotate', description: 'Ruota la stanza' },
|
{ key: 'zoom', descriptionKey: 'chatcmd.client.zoom' },
|
||||||
{ key: 'zoom', description: 'Zoom stanza' },
|
{ key: 'flip', descriptionKey: 'chatcmd.client.flip' },
|
||||||
{ key: 'flip', description: 'Reset zoom' },
|
{ key: 'iddqd', descriptionKey: 'chatcmd.client.iddqd' },
|
||||||
{ key: 'iddqd', description: 'Reset zoom' },
|
{ key: 'screenshot', descriptionKey: 'chatcmd.client.screenshot' },
|
||||||
{ key: 'screenshot', description: 'Screenshot stanza' },
|
{ key: 'togglefps', descriptionKey: 'chatcmd.client.togglefps' },
|
||||||
{ key: 'togglefps', description: 'Toggle FPS' },
|
// Expressions
|
||||||
// Espressioni
|
{ key: 'd', descriptionKey: 'chatcmd.client.laugh' },
|
||||||
{ key: 'd', description: 'Ridi (VIP)' },
|
{ key: 'kiss', descriptionKey: 'chatcmd.client.kiss' },
|
||||||
{ key: 'kiss', description: 'Manda un bacio (VIP)' },
|
{ key: 'jump', descriptionKey: 'chatcmd.client.jump' },
|
||||||
{ key: 'jump', description: 'Salta (VIP)' },
|
{ key: 'idle', descriptionKey: 'chatcmd.client.idle' },
|
||||||
{ key: 'idle', description: 'Vai in idle' },
|
{ key: 'sign', descriptionKey: 'chatcmd.client.sign' },
|
||||||
{ key: 'sign', description: 'Mostra cartello' },
|
// Room management
|
||||||
// Gestione stanza
|
{ key: 'furni', descriptionKey: 'chatcmd.client.furni' },
|
||||||
{ key: 'furni', description: 'Furni chooser' },
|
{ key: 'chooser', descriptionKey: 'chatcmd.client.chooser' },
|
||||||
{ key: 'chooser', description: 'User chooser' },
|
{ key: 'floor', descriptionKey: 'chatcmd.client.floor' },
|
||||||
{ key: 'floor', description: 'Floor editor' },
|
{ key: 'bcfloor', descriptionKey: 'chatcmd.client.floor' },
|
||||||
{ key: 'bcfloor', description: 'Floor editor' },
|
{ key: 'pickall', descriptionKey: 'chatcmd.client.pickall' },
|
||||||
{ key: 'pickall', description: 'Raccogli tutti i furni' },
|
{ key: 'ejectall', descriptionKey: 'chatcmd.client.ejectall' },
|
||||||
{ key: 'ejectall', description: 'Espelli tutti i furni' },
|
{ key: 'settings', descriptionKey: 'chatcmd.client.settings' },
|
||||||
{ key: 'settings', description: 'Impostazioni stanza' },
|
|
||||||
// Info
|
// Info
|
||||||
{ key: 'client', description: 'Info client' },
|
{ key: 'client', descriptionKey: 'chatcmd.client.info' },
|
||||||
{ key: 'nitro', description: 'Info client' },
|
{ key: 'nitro', descriptionKey: 'chatcmd.client.info' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,11 +109,12 @@ export const useChatCommandSelector = (chatValue: string) =>
|
|||||||
|
|
||||||
const allCommands = useMemo(() =>
|
const allCommands = useMemo(() =>
|
||||||
{
|
{
|
||||||
const merged = [ ...serverCommands ];
|
const merged: CommandDefinition[] = [ ...serverCommands ];
|
||||||
|
|
||||||
for(const clientCmd of CLIENT_COMMANDS)
|
for(const clientCmd of CLIENT_COMMANDS)
|
||||||
{
|
{
|
||||||
if(!merged.some(cmd => cmd.key === clientCmd.key)) merged.push(clientCmd);
|
if(merged.some(cmd => cmd.key === clientCmd.key)) continue;
|
||||||
|
merged.push({ key: clientCmd.key, description: LocalizeText(clientCmd.descriptionKey) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged.sort((a, b) => a.key.localeCompare(b.key));
|
return merged.sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
|||||||
Reference in New Issue
Block a user