feat(points): add effect engines and effects shop

This commit is contained in:
lobo committed 2026-10-01 15:09:25 +02:00
1 parent 2ec5833f29
commit dd45c0b5f6
12 files changed
+697 -11

No files matched your search

+18 -1
View File
@@ -1,3 +1,4 @@
import {useRef} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Coins} from 'lucide-react';
@@ -8,8 +9,10 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {Skeleton} from '@/components/ui/skeleton';
import {cn} from '@/lib/utils';
import EffectBackground from '../components/EffectBackground.jsx';
import PageContainer from '../components/PageContainer.jsx';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useInView from '../hooks/useInView.js';
import {formatDate} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
@@ -143,9 +146,13 @@ export default function PointsPage() {
);
}
// A sample of the look: a ring on a circle, a pill with the badge icon, a color dot or a framed box.
// A sample of the look: a ring on a circle, a pill with the badge icon, a color dot, a framed box, a tile with its
// effect or a box with the animated background.
function Preview({code}) {
const look = LOOKS[code];
const ref = useRef(null);
// The shop shows 20 animated previews at once; the ones off screen hold their frame.
const inView = useInView(ref, !!(look?.cover || look?.effect));
if (!look) return null;
if (look.Icon) {
return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>;
@@ -153,5 +160,15 @@ function Preview({code}) {
if (look.accent) {
return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>;
}
if (look.cover) {
return <span ref={ref} className={cn('relative h-8 w-14 shrink-0 overflow-hidden rounded-md bg-muted', look.cover, !inView && 'fx-paused')}/>;
}
if (look.effect) {
return (
<span ref={ref} className={cn('relative h-8 w-14 shrink-0 overflow-hidden rounded-md bg-background', !inView && 'fx-paused')}>
<EffectBackground effect={look.effect} className="absolute inset-0"/>
</span>
);
}
return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>;
}
+40 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import {toast} from 'sonner';
import PointsPage from './PointsPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
@@ -16,6 +16,9 @@ const wallet = (extra = {}) => ({
{code: 'BORDER_GOLD', slot: 'AVATAR_BORDER', price: 150},
{code: 'BORDER_NEON', slot: 'AVATAR_BORDER', price: 200},
{code: 'BADGE_PILE_SLAYER', slot: 'BADGE', price: 100},
{code: 'FX_GOLD_SHINE', slot: 'COMPLETED_EFFECT', price: 300},
{code: 'BG_GRID', slot: 'PAGE_BACKGROUND', price: 450},
{code: 'BN_GRID', slot: 'BANNER_BACKGROUND', price: 300},
],
owned: ['BADGE_PILE_SLAYER'],
equipped: {},
@@ -47,6 +50,42 @@ afterEach(() => {
});
describe('PointsPage', () => {
it('shows the effect slots with a preview of each look', async () => {
await renderPage();
const card = (slot) => screen.getByText(`POINTS.SLOTS.${slot}`).closest('[data-slot="card"]');
const cover = within(card('COMPLETED_EFFECT')).getByText('POINTS.ITEMS.FX_GOLD_SHINE').closest('li').firstChild;
expect(cover.classList.contains('fx-cover-gold-shine')).toBe(true);
expect(card('PAGE_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull();
expect(card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull();
});
it('holds the frame of previews that are off screen', async () => {
const callbacks = [];
vi.stubGlobal('IntersectionObserver', class {
constructor(callback) { callbacks.push(callback); }
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
});
try {
await renderPage();
const card = (slot) => screen.getByText(`POINTS.SLOTS.${slot}`).closest('[data-slot="card"]');
const cover = within(card('COMPLETED_EFFECT')).getByText('POINTS.ITEMS.FX_GOLD_SHINE').closest('li').firstChild;
const banner = card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]').parentElement;
expect(cover.classList.contains('fx-paused')).toBe(false);
act(() => callbacks[0]([{target: cover, isIntersecting: false}, {target: banner, isIntersecting: false}]));
expect(cover.classList.contains('fx-paused')).toBe(true);
expect(banner.classList.contains('fx-paused')).toBe(true);
act(() => callbacks[0]([{target: cover, isIntersecting: true}]));
expect(cover.classList.contains('fx-paused')).toBe(false);
} finally {
vi.unstubAllGlobals();
}
});
it('groups the catalog by slot and disables Buy below the price', async () => {
await renderPage();