5180 fichiers 38.5 Mo 0 téléchargements 254 vues mis à jour il y a 5 heures ui
nitro-v36-with-renderer-v36 / src / components / quests / SeasonalCalendarView.tsx
SeasonalCalendarView.tsx 19.3 Ko Télécharger le fichier
import { CreateLinkEvent, GetSessionDataManager, QuestMessageData } from '@octane/renderer';
import { CSSProperties, FC, PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import arrowBackActive from '../../assets/images/quests/seasonal-calendar/arrow_back_active.png';
import arrowBackHilite from '../../assets/images/quests/seasonal-calendar/arrow_back_hilite.png';
import arrowBackInactive from '../../assets/images/quests/seasonal-calendar/arrow_back_inactive.png';
import arrowNextActive from '../../assets/images/quests/seasonal-calendar/arrow_next_active.png';
import arrowNextHilite from '../../assets/images/quests/seasonal-calendar/arrow_next_hilite.png';
import arrowNextInactive from '../../assets/images/quests/seasonal-calendar/arrow_next_inactive.png';
import questCompleteIcon from '../../assets/images/quests/seasonal-calendar/calendar_quest_complete.png';
import stripeMaskLeft from '../../assets/images/quests/seasonal-calendar/stripe_mask_L.png';
import stripeMaskRight from '../../assets/images/quests/seasonal-calendar/stripe_mask_R.png';
import {
    adjustSeasonalBrightness,
    buildSeasonalCalendarDays,
    GetConfigurationValue,
    getFlashingSeasonalDay,
    getHighestSeasonalDay,
    getInitialSeasonalOffset,
    getRareTeaserState,
    getSeasonalBackgroundCount,
    getSeasonalDayImageName,
    getSeasonalImageHost,
    getSeasonalScrollLimits,
    LocalizeText,
    localizeWithFallback,
    parseSeasonalIntList,
    parseSeasonalStringList,
    SEASONAL_BACKGROUND_WIDTH,
    SEASONAL_DAY_ACTIVE,
    SEASONAL_DAY_COMPLETED,
    SEASONAL_ENTITIES_LEFT_MARGIN,
    SEASONAL_ENTITY_SPACING,
    SEASONAL_FLASH_MAX_BRIGHTNESS,
    SEASONAL_HOVER_BRIGHTEN,
    SEASONAL_INDICATOR_COLORS,
    SeasonalCalendarDay,
    toCssColor
} from '../../api';
import { Button, DraggableWindowPosition, LayoutCurrencyIcon, LayoutFurniImageView } from '../../common';
import { BaseButton } from '../../common/BaseButton';
import { getSeasonalCalendarSettings, getSeasonalQuestRoomIds, usePurse, useQuests, useRoom, useSeasonalCalendar } from '../../hooks';
import { OctaneCard } from '../../layout';
import { QuestEntryView } from './QuestEntryView';
import { NativeTextTree } from '../../common/native-text/NativeTextTree';

/** Hold an arrow and the strip keeps moving, one day per step (Calendar.update). */
const SCROLL_REPEAT_MS = 150;

/** `${key}` when the external texts have it, the fallback otherwise. */
const localizeKeyOr = (key: string, fallback: string): string => {
    const value = LocalizeText(key);

    return value && value !== key ? value : fallback;
};

const SeasonalArrow: FC<{ direction: 'back' | 'next'; active: boolean; onStep: () => void }> = ({ direction, active, onStep }) => {
    const [hover, setHover] = useState(false);
    const [pressed, setPressed] = useState(false);
    const repeatRef = useRef<number>(null);
    const stepRef = useRef(onStep);

    useEffect(() => {
        stepRef.current = onStep;
    }, [onStep]);

    const stop = useCallback(() => {
        setPressed(false);

        if (repeatRef.current !== null) window.clearInterval(repeatRef.current);

        repeatRef.current = null;
    }, []);

    useEffect(() => stop, [stop]);

    useEffect(() => {
        if (!active) stop();
    }, [active, stop]);

    const images = direction === 'back' ? [arrowBackInactive, arrowBackActive, arrowBackHilite] : [arrowNextInactive, arrowNextActive, arrowNextHilite];
    const image = !active ? images[0] : hover ? images[2] : images[1];

    const onPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
        if (!active) return;

        event.preventDefault();
        setPressed(true);
        stepRef.current();
        repeatRef.current = window.setInterval(() => stepRef.current(), SCROLL_REPEAT_MS);
    };

    return (
        <NativeTextTree>
            <BaseButton
                type="button"
                className={`octane-seasonal-arrow octane-seasonal-arrow-${direction}${pressed ? ' octane-seasonal-arrow-pressed' : ''}`}
                aria-label={direction === 'back' ? 'Previous days' : 'Next days'}
                disabled={!active}
                onPointerDown={onPointerDown}
                onPointerUp={stop}
                onPointerLeave={() => {
                    setHover(false);
                    stop();
                }}
                onPointerEnter={() => setHover(true)}
            >
                <img src={image} alt="" draggable={false} />
            </BaseButton>
        </NativeTextTree>
    );
};

const SeasonalDayView: FC<{
    day: SeasonalCalendarDay<QuestMessageData>;
    imageHost: string;
    prefix: string;
    flashing: boolean;
    onOpen: (quest: QuestMessageData) => void;
}> = ({ day, imageHost, prefix, flashing, onOpen }) => {
    const [hover, setHover] = useState(false);
    const [imageFailed, setImageFailed] = useState(false);
    const baseColor = SEASONAL_INDICATOR_COLORS[day.state];
    const color = hover ? adjustSeasonalBrightness(baseColor, SEASONAL_HOVER_BRIGHTEN) : baseColor;
    const clickable = day.state === SEASONAL_DAY_ACTIVE && !!day.quest;
    const label = day.quest
        ? localizeKeyOr(`quests.${day.quest.campaignCode}.name`, String(day.day))
        : localizeKeyOr(`quests.${prefix}_${day.day}.name`, String(day.day));
    const style = {
        left: `${SEASONAL_ENTITIES_LEFT_MARGIN + (day.day - 1) * SEASONAL_ENTITY_SPACING}px`,
        '--seasonal-indicator': toCssColor(color),
        '--seasonal-indicator-flash': toCssColor(adjustSeasonalBrightness(color, SEASONAL_FLASH_MAX_BRIGHTNESS))
    } as CSSProperties;

    useEffect(() => setImageFailed(false), [imageHost, day.day, day.state]);

    return (
        <NativeTextTree>
            <div className={`octane-seasonal-day octane-seasonal-day-state-${day.state}`} style={style}>
                {!imageFailed && (
                    <img
                        className="octane-seasonal-day-image"
                        src={`${imageHost}${getSeasonalDayImageName(day.day, day.state)}.png`}
                        alt=""
                        draggable={false}
                        onError={() => setImageFailed(true)}
                    />
                )}
                <div className="octane-seasonal-day-indicator-edge" />
                <div className={`octane-seasonal-day-indicator${flashing ? ' octane-seasonal-day-indicator-flash' : ''}`}>
                    <span className="octane-seasonal-day-indicator-text">{label}</span>
                    {day.state === SEASONAL_DAY_COMPLETED && <img className="octane-seasonal-day-indicator-status" src={questCompleteIcon} alt="" />}
                </div>
                {clickable && (
                    <BaseButton
                        type="button"
                        className="octane-seasonal-day-region"
                        aria-label={label}
                        onClick={() => onOpen(day.quest)}
                        onPointerEnter={() => setHover(true)}
                        onPointerLeave={() => setHover(false)}
                    />
                )}
            </div>
        </NativeTextTree>
    );
};

/** CatalogPromo: the offer of the day, its furni, and the balance of the calendar currency. */
const SeasonalCatalogPromoView: FC<{ currency: number }> = ({ currency }) => {
    const { dailyOffer = null } = useSeasonalCalendar();
    const { getCurrencyAmount = null } = usePurse();
    const offer = dailyOffer?.offer ?? null;
    const product = offer?.products?.[0] ?? null;
    const productType = product ? String(product.productType).toLowerCase() : '';
    const balance = getCurrencyAmount ? getCurrencyAmount(currency) : 0;

    const productName = useMemo(() => {
        if (!offer) return '';

        const productData = GetSessionDataManager().getProductData(offer.localizationId);

        if (productData?.name) return productData.name;

        if (!product) return '';

        const furniData = productType === 'i' ? GetSessionDataManager().getWallItemData(product.furniClassId) : GetSessionDataManager().getFloorItemData(product.furniClassId);

        return furniData?.name ?? '';
    }, [offer, product, productType]);

    return (
        <NativeTextTree>
            <div className="octane-seasonal-promo">
                <div className="octane-seasonal-promo-preview">
                    {product && (productType === 's' || productType === 'i') && (
                        <LayoutFurniImageView productType={product.productType} productClassId={product.furniClassId} extraData={product.extraParam} direction={2} />
                    )}
                </div>
                <div className="octane-seasonal-promo-info">
                    <div className="octane-seasonal-promo-header">{localizeWithFallback('quests.seasonalcalendar.promo.header', 'Offer of the day')}</div>
                    <div className="octane-seasonal-promo-name">{productName}</div>
                    <div className="octane-seasonal-promo-balance">
                        <span>{localizeWithFallback('quests.seasonalcalendar.promo.balance', 'You have: %amount%', ['amount'], [String(balance)])}</span>
                        <LayoutCurrencyIcon type={currency} />
                    </div>
                    <Button
                        className="octane-seasonal-promo-buy"
                        variant="success"
                        disabled={!offer}
                        onClick={() => offer && CreateLinkEvent(`catalog/open/offerId/${offer.offerId}`)}
                    >
                        {localizeWithFallback('quests.seasonalcalendar.promo.buy', 'To the Shop')}
                    </Button>
                </div>
            </div>
        </NativeTextTree>
    );
};

/** RareTeaser: three rares that unlock on configured days, from the calendar gallery. */
const SeasonalRareTeaserView: FC<{ highestDay: number; imageHost: string }> = ({ highestDay, imageHost }) => {
    const days = parseSeasonalIntList(LocalizeText('quests.seasonalcalendar.rareteaser.days'));
    const images = parseSeasonalStringList(LocalizeText('quests.seasonalcalendar.rareteaser.images'));
    const pages = parseSeasonalStringList(LocalizeText('quests.seasonalcalendar.rareteaser.pages'));
    const { locked, daysUntilNext } = getRareTeaserState(days.slice(0, 3), highestDay);

    return (
        <NativeTextTree>
            <div className="octane-seasonal-teaser">
                <div className="octane-seasonal-teaser-header">{localizeWithFallback('quests.seasonalcalendar.rareteaser.header', 'Unlocked items')}</div>
                <div className="octane-seasonal-teaser-rares">
                    {locked.map((isLocked, index) => (
                        <BaseButton
                            key={index}
                            type="button"
                            className={`octane-seasonal-teaser-rare${isLocked ? ' octane-seasonal-teaser-rare-locked' : ''}`}
                            disabled={isLocked || !pages[index]}
                            aria-label={isLocked ? 'Locked' : (pages[index] ?? '')}
                            onClick={() => pages[index] && CreateLinkEvent(`catalog/open/${pages[index]}`)}
                        >
                            {isLocked ? <span className="octane-seasonal-teaser-lock" /> : images[index] && <img src={`${imageHost}${images[index]}.png`} alt="" draggable={false} />}
                        </BaseButton>
                    ))}
                </div>
                {daysUntilNext !== -1 && (
                    <div className="octane-seasonal-teaser-info">
                        {localizeWithFallback('quests.seasonalcalendar.rareteaser.info', '%days% days until the next unlock.', ['days'], [String(daysUntilNext)])}
                    </div>
                )}
            </div>
        </NativeTextTree>
    );
};

/**
 * The official seasonal quest calendar (SeasonalCalendar.xml, 642x465): a strip of days with the
 * campaign images of `${image.library.url}<prefix>_quest_calendar/`, the catalogue promo of the
 * day and the rare teaser. Clicking today's day opens the quest details from the calendar.
 */
export const SeasonalCalendarView: FC<{}> = () => {
    const { enabled = false, prefix = '', quests = [], isVisible = false, close = null, goToQuestRoom = null } = useSeasonalCalendar();
    const { acceptQuest = null, rejectQuest = null } = useQuests();
    const { roomSession = null } = useRoom();
    const [offset, setOffset] = useState(-1);
    const [detailsQuest, setDetailsQuest] = useState<QuestMessageData>(null);
    const previousRoomRef = useRef(roomSession);
    const { currency, maximumEntities } = getSeasonalCalendarSettings();
    const imageHost = getSeasonalImageHost(GetConfigurationValue<string>('image.library.url', ''), prefix);

    const days = useMemo(() => buildSeasonalCalendarDays(quests, prefix, maximumEntities), [quests, prefix, maximumEntities]);
    const highestDay = useMemo(() => getHighestSeasonalDay(quests, prefix), [quests, prefix]);
    const flashingDay = getFlashingSeasonalDay(days, highestDay);
    const currentOffset = offset < 0 ? getInitialSeasonalOffset(highestDay, maximumEntities) : offset;
    const { canScrollBack, canScrollForward } = getSeasonalScrollLimits(currentOffset, days.length, maximumEntities, highestDay);
    const backgroundCount = getSeasonalBackgroundCount(days.length);

    // Calendar: the first answer places today three days from the left edge, later ones keep the scroll.
    useEffect(() => {
        if (offset < 0 && quests.length) setOffset(getInitialSeasonalOffset(highestDay, maximumEntities));
    }, [offset, quests.length, highestDay, maximumEntities]);

    // MainWindow.onRoomExit: leaving a room closes the calendar.
    useEffect(() => {
        if (previousRoomRef.current && !roomSession && close) close();

        previousRoomRef.current = roomSession;
    }, [roomSession, close]);

    useEffect(() => {
        if (!detailsQuest) return;

        const current = quests.find((quest) => quest.campaignCode === detailsQuest.campaignCode);

        if (current && current !== detailsQuest) setDetailsQuest(current.completedCampaign ? null : current);
    }, [quests, detailsQuest]);

    // The forward arrow stays active while offset < this, so a held arrow stops on the last step.
    const maxOffset = Math.max(0, Math.min(days.length - 4, maximumEntities - 7, highestDay - 1));
    const scrollBack = useCallback(() => setOffset((value) => Math.max(0, (value < 0 ? currentOffset : value) - 1)), [currentOffset]);
    const scrollForward = useCallback(() => setOffset((value) => Math.min(maxOffset, (value < 0 ? currentOffset : value) + 1)), [currentOffset, maxOffset]);

    if (!enabled) return null;

    const hasRoomLink = getSeasonalQuestRoomIds(prefix).length > 0;

    return (
        <NativeTextTree>
            <>
                {isVisible && (
                    <OctaneCard className="octane-seasonal-calendar" uniqueKey="seasonal-calendar" windowPosition={DraggableWindowPosition.CENTER}>
                        <OctaneCard.Header headerText={localizeWithFallback(`quests.${prefix}.title`, localizeWithFallback('quests.list.caption', 'Quests'))} onCloseClick={() => close && close()} />
                        <OctaneCard.Content className="octane-seasonal-calendar-content">
                            <div className="octane-seasonal-strip-window">
                                <div className="octane-seasonal-strip" style={{ transform: `translateX(-${currentOffset * SEASONAL_ENTITY_SPACING}px)` }}>
                                    {Array.from({ length: backgroundCount }, (_, index) => (
                                        <img
                                            key={`background-${index}`}
                                            className="octane-seasonal-background"
                                            style={{ left: `${index * SEASONAL_BACKGROUND_WIDTH}px` }}
                                            src={`${imageHost}background_${index + 1}.png`}
                                            alt=""
                                            draggable={false}
                                            onError={(event) => {
                                                event.currentTarget.style.visibility = 'hidden';
                                            }}
                                        />
                                    ))}
                                    {days.map((day) => (
                                        <SeasonalDayView key={day.day} day={day} imageHost={imageHost} prefix={prefix} flashing={day.day === flashingDay} onOpen={setDetailsQuest} />
                                    ))}
                                </div>
                                <img className="octane-seasonal-stripe-mask octane-seasonal-stripe-mask-left" src={stripeMaskLeft} alt="" draggable={false} />
                                <img className="octane-seasonal-stripe-mask octane-seasonal-stripe-mask-right" src={stripeMaskRight} alt="" draggable={false} />
                                <SeasonalArrow direction="back" active={canScrollBack} onStep={scrollBack} />
                                <SeasonalArrow direction="next" active={canScrollForward} onStep={scrollForward} />
                            </div>
                            <div className="octane-seasonal-footer">
                                <SeasonalCatalogPromoView currency={currency} />
                                <SeasonalRareTeaserView highestDay={highestDay} imageHost={imageHost} />
                            </div>
                        </OctaneCard.Content>
                    </OctaneCard>
                )}
                {detailsQuest && (
                    <OctaneCard className="octane-quest-details" uniqueKey="seasonal-quest-details" windowPosition={DraggableWindowPosition.CENTER}>
                        <OctaneCard.Header headerText={localizeWithFallback('quests.details.caption', 'Quest details')} onCloseClick={() => setDetailsQuest(null)} />
                        <OctaneCard.Content className="octane-quest-details-content">
                            <QuestEntryView
                                quest={detailsQuest}
                                showHint
                                onAccept={(quest) => {
                                    // QuestDetails opened from the calendar: accept, close the calendar, go to a quest room.
                                    acceptQuest && acceptQuest(quest.id);
                                    setDetailsQuest(null);
                                    close && close();
                                    if (hasRoomLink && goToQuestRoom) goToQuestRoom();
                                }}
                                onReject={(quest) => {
                                    rejectQuest && rejectQuest(quest.id);
                                    setDetailsQuest(null);
                                }}
                            />
                            {hasRoomLink && detailsQuest.waitPeriodSeconds < 1 && (
                                <BaseButton className="octane-quest-link" onClick={() => goToQuestRoom && goToQuestRoom()}>
                                    {localizeWithFallback('quests.gotocampaignroom', 'Go to the campaign room')}
                                </BaseButton>
                            )}
                        </OctaneCard.Content>
                    </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