Public Access
feat(points): add effect engines and effects shop
This commit is contained in:
1 parent
2ec5833f29
commit
dd45c0b5f6
12 files changed
+697
-11
No files matched your search
@@ -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')}/>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user