5180 fichiers 38.5 Mo 0 téléchargements 252 vues mis à jour il y a 5 heures ui
FortuneWheelView.tsx 17.6 Ko Télécharger le fichier
import { AddLinkEventTracker, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@octane/renderer';
import { FC, TransitionEvent, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText } from '../../api';
import { Column, Flex, LayoutAvatarImageView, LayoutCurrencyIcon, Text } from '../../common';
import { useFortuneWheel, useHasPermission } from '../../hooks';
import { OctaneCard } from '../../layout';
import { FortuneWheelSettingsView } from './FortuneWheelSettingsView';
import { WheelWinReveal } from './WheelWinReveal';
import { renderPrizeIcon } from './wheelPrizeIcon';
import { BaseButton } from '../../common/BaseButton';
import { NativeTextTree } from '../../common/native-text/NativeTextTree';

// Stock UI palette (white / light-blue / grey / black). Exposed as CSS custom
// properties so a runtime theme can recolor the wheel without changing defaults
// (the fallback values keep the stock look when no theme overrides them).
const SLICE_COLORS = ['var(--wheel-slice-1, #eef2f5)', 'var(--wheel-slice-2, #c3dcec)'];
const RIM = 'var(--wheel-rim, #4c606c)';
const DIVIDER = 'var(--wheel-divider, rgba(76,96,108,0.3))';
const HUB = 'var(--wheel-hub, #eef2f5)';
const WHEEL_SIZE = 420;
const ICON_RADIUS = 150;
const FULL_TURNS = 5;

// Spin motion (wind-back → fast spin past target → settle back).
const WINDBACK_DEG = 14;
const OVERSHOOT_DEG = 16;
const WINDBACK_MS = 250;
const SPIN_MS = 4000;
const SETTLE_MS = 550;

type SpinPhase = 'idle' | 'windback' | 'spin' | 'settle';

export const FortuneWheelView: FC<{}> = () => {
    const [isVisible, setIsVisible] = useState(false);
    const [isSettingsOpen, setIsSettingsOpen] = useState(false);
    const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, isRequestPending, open, spin, buySpin, finishSpin } =
        useFortuneWheel();
    const canManage = useHasPermission('acc_wheeladmin');

    const [rotation, setRotation] = useState(0);
    const [phase, setPhase] = useState<SpinPhase>('idle');
    const [revealPrize, setRevealPrize] = useState<IWheelPrize | null>(null);
    const [wheelScale, setWheelScale] = useState(1);

    const rotationRef = useRef(0);
    const targetRef = useRef(0);
    const phaseRef = useRef<SpinPhase>('idle');
    const wonPrizeRef = useRef<IWheelPrize | null>(null);
    const prizesRef = useRef<IWheelPrize[]>([]);
    const wheelHostRef = useRef<HTMLDivElement>(null);
    prizesRef.current = prizes;

    const reducedMotion = useMemo(() => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches, []);

    const setSpinPhase = (next: SpinPhase) => {
        phaseRef.current = next;
        setPhase(next);
    };

    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]);

    // Keep the wheel fitting its container on narrow viewports without
    // rewriting the px-based slice/icon math: measure the available width
    // and scale the whole wheel down to fit.
    useEffect(() => {
        const host = wheelHostRef.current;
        if (!host || typeof ResizeObserver === 'undefined') return;

        const observer = new ResizeObserver((entries) => {
            const width = entries[0]?.contentRect.width ?? WHEEL_SIZE;
            setWheelScale(Math.min(1, width / WHEEL_SIZE));
        });
        observer.observe(host);

        return () => observer.disconnect();
    }, [isVisible]);

    // 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;
        }

        wonPrizeRef.current = list[idx];

        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);
        targetRef.current = target;

        // A closed window has no wheel whose transition could end the spin, so it ended never: the spin button
        // stayed disabled until a reload. Land on the prize at once; the reveal waits for the next open.
        if (!isVisible) {
            setSpinPhase('idle');
            rotationRef.current = target;
            setRotation(target);
            setRevealPrize(list[idx]);
            finishSpin();
            return;
        }

        if (reducedMotion) {
            // Single straightforward move to the target, no flourish.
            setSpinPhase('spin');
            rotationRef.current = target;
            setRotation(target);
            return;
        }

        // Phase 1: tiny anticipation wind-back before the spin.
        setSpinPhase('windback');
        const back = current - WINDBACK_DEG;
        rotationRef.current = back;
        setRotation(back);
    }, [pendingPrizeId, finishSpin, reducedMotion, isVisible]);

    const finishReveal = () => {
        setSpinPhase('idle');
        setRevealPrize(wonPrizeRef.current);
        finishSpin();
    };

    const handleTransitionEnd = (event: TransitionEvent<HTMLDivElement>) => {
        // Only react to the wheel's own transform transition finishing. Child
        // elements (prize icons, badges) can emit their own bubbling
        // transitionend events; without this guard they'd advance the spin
        // phase machine early and reveal the prize before the wheel stops.
        if (event.target !== event.currentTarget || event.propertyName !== 'transform') return;

        switch (phaseRef.current) {
            case 'windback':
                // Phase 2: spin fast, overshooting the target slightly.
                setSpinPhase('spin');
                rotationRef.current = targetRef.current + OVERSHOOT_DEG;
                setRotation(rotationRef.current);
                return;
            case 'spin':
                if (reducedMotion) {
                    finishReveal();
                    return;
                }
                // Phase 3: settle back from the overshoot onto the target.
                setSpinPhase('settle');
                rotationRef.current = targetRef.current;
                setRotation(rotationRef.current);
                return;
            case 'settle':
                finishReveal();
                return;
        }
    };

    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]);

    const wheelTransition = useMemo(() => {
        switch (phase) {
            case 'windback':
                return `transform ${WINDBACK_MS}ms ease-in`;
            case 'spin':
                return `transform ${SPIN_MS}ms cubic-bezier(0.12,0.78,0.2,1)`;
            case 'settle':
                return `transform ${SETTLE_MS}ms ease-out`;
            default:
                return 'none';
        }
    }, [phase]);

    if (!isVisible) return null;

    const canSpin = freeSpins + extraSpins > 0 && !isSpinning && !isRequestPending && prizes.length > 0;

    return (
        <NativeTextTree>
            <OctaneCard className="wheel-card w-[780px] max-w-[96vw]" uniqueKey="fortune-wheel">
                <OctaneCard.Header headerText={LocalizeText('wheel.title')} onCloseClick={() => setIsVisible(false)} />
                <OctaneCard.Content>
                    <div className="relative">
                        <Flex gap={3} className="flex-col sm:flex-row">
                            <Column alignItems="center" gap={2} className="w-full shrink-0 sm:w-[420px]">
                                <div ref={wheelHostRef} className="relative w-full" style={{ height: WHEEL_SIZE * wheelScale }}>
                                    <div
                                        className="absolute left-1/2 top-0"
                                        style={{
                                            width: WHEEL_SIZE,
                                            height: WHEEL_SIZE,
                                            transform: `translateX(-50%) scale(${wheelScale})`,
                                            transformOrigin: 'top center'
                                        }}
                                    >
                                        <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: wheelTransition }}
                                            onTransitionEnd={handleTransitionEnd}
                                        >
                                            {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: DIVIDER
                                                    }}
                                                />
                                            ))}
                                            {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="wheel-slice-icon -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 shadow-[0_0_8px_rgba(0,0,0,0.25)]"
                                            style={{ border: `4px solid ${RIM}`, background: HUB }}
                                        />
                                    </div>
                                </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" className="flex-wrap justify-center">
                                    <BaseButton
                                        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')}
                                    </BaseButton>
                                    <BaseButton
                                        disabled={isRequestPending}
                                        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} />
                                    </BaseButton>
                                    {canManage && (
                                        <BaseButton
                                            onClick={() => setIsSettingsOpen(true)}
                                            className="cursor-pointer rounded bg-[#8a6b3a] px-3 py-2 font-bold text-white hover:bg-[#735730]"
                                        >
                                            {LocalizeText('wheel.settings')}
                                        </BaseButton>
                                    )}
                                </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] max-h-[60vh]">
                                    {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>
                        {revealPrize && <WheelWinReveal prize={revealPrize} onDismiss={() => setRevealPrize(null)} />}
                    </div>
                </OctaneCard.Content>
                {canManage && isSettingsOpen && <FortuneWheelSettingsView onClose={() => setIsSettingsOpen(false)} />}
            </OctaneCard>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
2En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser