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

+84
View File
@@ -0,0 +1,84 @@
import {cn} from '@/lib/utils';
export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh'];
const range = (count) => Array.from({length: count}, (_, i) => i);
// Three groups of eight triangles on a 1600x900 canvas; a fixed pattern, so the picture is the same on every visit.
const TRIANGLES = range(3).map((group) => range(8).map((i) => {
const x = (i * 211 + group * 97) % 1600;
const y = (i * 137 + group * 263) % 900;
const size = 80 + ((i * 53 + group * 29) % 90);
return {
points: `${x},${y - size} ${x - size * 0.87},${y + size / 2} ${x + size * 0.87},${y + size / 2}`,
rotate: `rotate(${i * 40} ${x} ${y})`,
opacity: 0.06 + ((i + group) % 4) * 0.04,
};
}));
// One period is 1600 units; two periods make the band 2 widths wide, so a move of one width loops without a jump.
const WAVE = 'M0 100 C200 30 600 30 800 100 C1000 170 1400 170 1600 100 C1800 30 2200 30 2400 100 '
+ 'C2600 170 3000 170 3200 100 L3200 200 L0 200 Z';
/**
* The markup of an animated page or banner background (CSS in effects.css). Fills its parent: pass `absolute inset-0`
* or `fixed inset-0`. Nothing here is read by a screen reader.
*
* @param effect one of EFFECTS; anything else renders nothing
*/
export default function EffectBackground({effect, className}) {
if (!EFFECTS.includes(effect)) return null;
return (
<div aria-hidden="true" data-effect={effect}
className={cn('fx fx-bg pointer-events-none overflow-hidden', `fx-bg-${effect}`, className)}>
{shapes(effect)}
</div>
);
}
function shapes(effect) {
switch (effect) {
case 'triangles':
return (
<svg className="size-full" viewBox="0 0 1600 900" preserveAspectRatio="xMidYMid slice">
{TRIANGLES.map((triangles, group) => (
<g key={group} fill="currentColor" className={group === 1 ? 'text-foreground' : undefined}>
{triangles.map((triangle) => (
<polygon key={triangle.points} points={triangle.points} transform={triangle.rotate}
opacity={triangle.opacity}/>
))}
</g>
))}
</svg>
);
case 'starfield':
return range(9).map((i) => <span key={i}/>);
case 'pixels':
return range(12).map((i) => (
<span key={i} style={{
'--x': (i * 37) % 100, '--y': (i * 53 + 11) % 90,
'--dur': `${25 + ((i * 7) % 16)}s`, '--delay': `${-i * 3.1}s`,
}}/>
));
case 'aurora':
return range(2).map((i) => <div key={i}/>);
case 'mesh':
return range(4).map((i) => <div key={i}/>);
case 'grid':
return range(2).map((i) => <div key={i}/>);
case 'waves':
return range(3).map((i) => (
<svg key={i} viewBox="0 0 3200 200" preserveAspectRatio="none">
<path d={WAVE} fill="currentColor"/>
</svg>
));
case 'bokeh':
return range(10).map((i) => (
<span key={i} style={{
'--x': (i * 29 + 7) % 90, '--y': (i * 47 + 10) % 90, '--s': 8 + ((i * 13) % 33),
'--dur': `${30 + ((i * 5) % 21)}s`, '--delay': `${-i * 4.3}s`,
}}/>
));
}
}
+26
View File
@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import EffectBackground, {EFFECTS} from './EffectBackground.jsx';
afterEach(cleanup);
describe('EffectBackground', () => {
it.each(EFFECTS)('renders %s hidden from screen readers with its engine class and shapes', (effect) => {
const {container} = render(<EffectBackground effect={effect} className="absolute inset-0"/>);
const root = container.querySelector('[data-effect]');
expect(root.dataset.effect).toBe(effect);
expect(root.getAttribute('aria-hidden')).toBe('true');
expect(root.classList.contains(`fx-bg-${effect}`)).toBe(true);
expect(root.classList.contains('fx')).toBe(true);
expect(root.classList.contains('absolute')).toBe(true);
expect(root.children.length).toBeGreaterThan(0);
});
it('renders nothing for an unknown effect', () => {
const {container} = render(<EffectBackground effect="matrix"/>);
expect(container.firstChild).toBeNull();
});
});
+355
View File
@@ -0,0 +1,355 @@
/*
* Pile Points effects (utils/rewards.js LOOKS, components/EffectBackground.jsx). Only transform and opacity move.
* Every effect looks good at frame 0, because that is the static picture: no animation, no motion.
* Colors come from --primary, --foreground and --logo-*, so an effect follows the accent and works in both modes.
* Backgrounds are a size container, so cq units size and move the shapes in a page and in a banner alike.
*/
@keyframes fx-sweep { 0% { transform: translateX(-100%); } 35%, 100% { transform: translateX(100%); } }
@keyframes fx-slide { 0% { transform: translateX(-33.33%); } 60%, 100% { transform: translateX(33.33%); } }
@keyframes fx-breathe { from { opacity: 0.4; } to { opacity: 1; } }
@keyframes fx-glow { from { opacity: 0.5; transform: scale(0.95); } to { opacity: 1; transform: scale(1.05); } }
@keyframes fx-spark-rise { from { transform: translateY(0); opacity: 1; } to { transform: translateY(-45%); opacity: 0; } }
@keyframes fx-ember { from { transform: translateY(0); opacity: 0.9; } to { transform: translateY(-3rem); opacity: 0; } }
@keyframes fx-twinkle { 0%, 100% { transform: scale(0.2); opacity: 0; } 50% { transform: scale(1); opacity: 1; } }
/* ---- COMPLETED_EFFECT, cover of a tile (GameCover has relative and overflow-hidden) ---- */
.fx-cover-gold-shine::after,
.fx-cover-neon-pulse::after,
.fx-cover-holo::after,
.fx-cover-ember::before,
.fx-cover-ember::after {
content: "";
position: absolute;
pointer-events: none;
}
/* The golden border is an inside outline in the look (a ring would be clipped by the card around a flush cover). */
.fx-cover-gold-shine::after {
inset: 0;
background: linear-gradient(115deg, transparent 40%, rgb(255 255 255 / 0.35) 50%, transparent 60%);
transform: translateX(-100%);
animation: fx-sweep 4s ease-in-out infinite;
}
/* The ring is an inside outline in the look; the glow is inside, because the cover clips outside shadows. */
.fx-cover-neon-pulse::after {
inset: 0;
border-radius: inherit;
box-shadow: inset 0 0 14px 2px var(--primary);
opacity: 0.7;
animation: fx-breathe 2.4s ease-in-out infinite alternate;
}
.fx-cover-holo { isolation: isolate; }
.fx-cover-holo::after {
top: 0;
bottom: 0;
left: -100%;
width: 300%;
mix-blend-mode: overlay;
background: linear-gradient(120deg,
transparent 30%,
color-mix(in oklab, var(--logo-teal) 45%, transparent) 40%,
color-mix(in oklab, var(--primary) 45%, transparent) 50%,
color-mix(in oklab, var(--logo-violet) 45%, transparent) 60%,
transparent 70%);
animation: fx-slide 6s ease-in-out infinite;
}
.fx-cover-ember::before {
inset: 0;
background: linear-gradient(to top, rgb(249 115 22 / 0.35), transparent 55%);
}
/* Three sparks as gradient dots in one layer: they rise together. */
.fx-cover-ember::after {
inset: 0;
background:
radial-gradient(circle 2px at 22% 90%, #fdba74 60%, transparent),
radial-gradient(circle 2px at 52% 78%, #fdba74 60%, transparent),
radial-gradient(circle 2px at 80% 92%, #fdba74 60%, transparent);
animation: fx-spark-rise 3s ease-out infinite;
}
/* ---- COMPLETED_EFFECT, details page (decoration behind the text of GameHeroCard; spans are the items) ---- */
.fx-detail-gold-shine > span,
.fx-detail-ember > span {
position: absolute;
left: var(--x);
top: var(--y);
animation-delay: var(--d);
}
.fx-detail-gold-shine > span {
width: 14px;
height: 14px;
background: #fbbf24;
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%);
transform: scale(0.6);
animation: fx-twinkle 2.6s ease-in-out infinite;
}
.fx-detail-gold-shine > :nth-child(1) { --x: 46%; --y: 12%; --d: 0s; }
.fx-detail-gold-shine > :nth-child(2) { --x: 62%; --y: 70%; --d: -0.4s; }
.fx-detail-gold-shine > :nth-child(3) { --x: 78%; --y: 18%; --d: -0.9s; }
.fx-detail-gold-shine > :nth-child(4) { --x: 92%; --y: 55%; --d: -1.3s; }
.fx-detail-gold-shine > :nth-child(5) { --x: 54%; --y: 42%; --d: -1.7s; }
.fx-detail-gold-shine > :nth-child(6) { --x: 85%; --y: 86%; --d: -2.1s; }
.fx-detail-gold-shine > :nth-child(7) { --x: 70%; --y: 44%; --d: -2.4s; }
.fx-detail-gold-shine > :nth-child(8) { --x: 40%; --y: 82%; --d: -0.2s; }
.fx-detail-neon-pulse > span {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 70% 40%, color-mix(in oklab, var(--primary) 25%, transparent), transparent 70%);
opacity: 0.8;
animation: fx-glow 2.4s ease-in-out infinite alternate;
}
/* A faint rainbow sheen over the top edge. */
.fx-detail-holo > span {
position: absolute;
inset: 0 0 auto;
height: 35%;
background: linear-gradient(90deg,
transparent,
color-mix(in oklab, var(--logo-teal) 18%, transparent),
color-mix(in oklab, var(--primary) 18%, transparent),
color-mix(in oklab, var(--logo-violet) 18%, transparent),
transparent);
animation: fx-breathe 6s ease-in-out infinite alternate;
}
.fx-detail-ember > span {
width: 4px;
height: 4px;
border-radius: 50%;
background: #fb923c;
box-shadow: 0 0 6px #f97316;
animation: fx-ember 3s ease-out infinite;
}
.fx-detail-ember > :nth-child(1) { --x: 46%; --y: 70%; --d: 0s; }
.fx-detail-ember > :nth-child(2) { --x: 58%; --y: 82%; --d: -0.5s; }
.fx-detail-ember > :nth-child(3) { --x: 68%; --y: 66%; --d: -1s; }
.fx-detail-ember > :nth-child(4) { --x: 78%; --y: 84%; --d: -1.5s; }
.fx-detail-ember > :nth-child(5) { --x: 88%; --y: 72%; --d: -2s; }
.fx-detail-ember > :nth-child(6) { --x: 52%; --y: 90%; --d: -2.5s; }
/* ---- PAGE_BACKGROUND and BANNER_BACKGROUND (EffectBackground) ---- */
.fx-bg { container-type: size; }
.fx-bg > * { position: absolute; }
/* triangles: three groups of triangles drifting apart */
@keyframes fx-tri-a { to { transform: translate(60px, -40px) rotate(5deg); } }
@keyframes fx-tri-b { to { transform: translate(-70px, 30px) rotate(-6deg); } }
@keyframes fx-tri-c { to { transform: translate(40px, 50px) rotate(4deg); } }
.fx-bg-triangles { color: var(--primary); }
.fx-bg-triangles g { transform-box: fill-box; transform-origin: center; }
.fx-bg-triangles g:nth-child(1) { animation: fx-tri-a 45s ease-in-out infinite alternate; }
.fx-bg-triangles g:nth-child(2) { animation: fx-tri-b 58s ease-in-out infinite alternate; }
.fx-bg-triangles g:nth-child(3) { animation: fx-tri-c 70s ease-in-out infinite alternate; }
/* starfield: three tiled layers (a tile of --t px moves up by one tile, so the loop is seamless) and 6 twinkles */
@keyframes fx-drift { to { transform: translateY(calc(var(--t) * -1)); } }
.fx-bg-starfield { --star: color-mix(in oklab, var(--foreground) 70%, transparent); }
.fx-bg-starfield > :nth-child(-n+3) {
inset: 0;
bottom: calc(var(--t) * -1);
background-size: var(--t) var(--t);
will-change: transform;
animation: fx-drift linear infinite;
}
.fx-bg-starfield > :nth-child(1) {
--t: 90px;
animation-duration: 60s;
background-image:
radial-gradient(circle at 20% 30%, var(--star) 0 1px, transparent 1.5px),
radial-gradient(circle at 70% 80%, var(--star) 0 1px, transparent 1.5px);
}
.fx-bg-starfield > :nth-child(2) {
--t: 140px;
animation-duration: 90s;
background-image:
radial-gradient(circle at 15% 65%, var(--star) 0 1px, transparent 1.5px),
radial-gradient(circle at 55% 20%, var(--star) 0 1px, transparent 1.5px),
radial-gradient(circle at 85% 75%, var(--star) 0 1px, transparent 1.5px);
}
.fx-bg-starfield > :nth-child(3) {
--t: 220px;
animation-duration: 120s;
background-image:
radial-gradient(circle at 30% 40%, var(--star) 0 1.5px, transparent 2px),
radial-gradient(circle at 75% 85%, var(--star) 0 1.5px, transparent 2px);
}
.fx-bg-starfield > :nth-child(n+4) {
left: var(--x);
top: var(--y);
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--star);
box-shadow: 0 0 6px var(--star);
opacity: 0.65;
animation: fx-breathe 3s ease-in-out infinite alternate;
animation-delay: var(--d);
}
.fx-bg-starfield > :nth-child(4) { --x: 12%; --y: 20%; --d: 0s; }
.fx-bg-starfield > :nth-child(5) { --x: 33%; --y: 62%; --d: -0.6s; }
.fx-bg-starfield > :nth-child(6) { --x: 51%; --y: 14%; --d: -1.2s; }
.fx-bg-starfield > :nth-child(7) { --x: 68%; --y: 48%; --d: -1.8s; }
.fx-bg-starfield > :nth-child(8) { --x: 84%; --y: 26%; --d: -2.4s; }
.fx-bg-starfield > :nth-child(9) { --x: 92%; --y: 78%; --d: -0.3s; }
/* pixels: squares rise and turn; fade in at the base spot, out at the top, so the loop shows no jump */
@keyframes fx-float {
0% { transform: translateY(0) rotate(0); opacity: 0; }
10%, 90% { opacity: 0.8; }
100% { transform: translateY(calc(var(--y) * -1cqh - 10px)) rotate(180deg); opacity: 0; }
}
.fx-bg-pixels > * {
left: calc(var(--x) * 1%);
top: calc(var(--y) * 1%);
width: 4px;
height: 4px;
opacity: 0.8;
background: var(--primary);
animation: fx-float var(--dur) linear infinite;
animation-delay: var(--delay);
}
.fx-bg-pixels > :nth-child(4n+2) { background: var(--logo-violet); }
.fx-bg-pixels > :nth-child(4n+3) { background: var(--logo-blue); }
.fx-bg-pixels > :nth-child(4n) { background: var(--logo-teal); }
/* aurora: two soft ribbons waving across the top (elliptic gradients have no hard edge) */
@keyframes fx-wave-a {
from { transform: rotate(-8deg) translateX(-10%) scaleY(1); }
to { transform: rotate(-8deg) translateX(10%) scaleY(1.3); }
}
.fx-bg-aurora > * {
left: -30%;
width: 160%;
height: 40%;
transform: rotate(-8deg);
will-change: transform;
animation: fx-wave-a 20s ease-in-out infinite alternate;
}
.fx-bg-aurora > :nth-child(1) {
top: -5%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 28%, transparent), transparent);
}
.fx-bg-aurora > :nth-child(2) {
top: 12%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 24%, transparent), transparent);
animation-duration: 26s;
animation-direction: alternate-reverse;
}
/* mesh: four big blobs in the corners; the gradient edge is the blur, a filter would cost every frame */
@keyframes fx-blob {
from { transform: translate(-8%, -6%) scale(0.9); }
to { transform: translate(8%, 6%) scale(1.1); }
}
.fx-bg-mesh > * {
width: 60cqmax;
height: 60cqmax;
will-change: transform;
animation: fx-blob 25s ease-in-out infinite alternate;
}
.fx-bg-mesh > :nth-child(1) {
left: -15%; top: -20%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 30%, transparent), transparent);
}
.fx-bg-mesh > :nth-child(2) {
right: -15%; top: -10%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 26%, transparent), transparent);
animation-duration: 28s;
}
.fx-bg-mesh > :nth-child(3) {
left: -10%; bottom: -25%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-violet) 26%, transparent), transparent);
animation-duration: 32s;
}
.fx-bg-mesh > :nth-child(4) {
right: -20%; bottom: -20%;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 22%, transparent), transparent);
animation-duration: 35s;
}
/* grid: a perspective floor moving one cell (40px) toward the viewer; the height keeps all points in front of the camera */
@keyframes fx-floor {
from { transform: perspective(600px) rotateX(62deg) translateY(0); }
to { transform: perspective(600px) rotateX(62deg) translateY(40px); }
}
.fx-bg-grid > :nth-child(1) {
inset: 0 0 auto;
height: 45%;
background: linear-gradient(to bottom, transparent, color-mix(in oklab, var(--primary) 25%, transparent));
}
.fx-bg-grid > :nth-child(2) {
left: -100%;
top: 45%;
width: 300%;
height: 500px;
transform-origin: top center;
transform: perspective(600px) rotateX(62deg);
background-image:
repeating-linear-gradient(0deg, color-mix(in oklab, var(--primary) 35%, transparent) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, color-mix(in oklab, var(--primary) 35%, transparent) 0 1px, transparent 1px 40px);
will-change: transform;
animation: fx-floor 2s linear infinite;
}
/* waves: three bands 2 container widths wide (one period = one width), moving one width sideways */
@keyframes fx-wave-x { to { transform: translateX(-50%); } }
.fx-bg-waves > svg {
left: 0;
bottom: 0;
width: 200%;
color: var(--primary);
animation: fx-wave-x linear infinite;
}
.fx-bg-waves > :nth-child(1) { height: 25%; opacity: 0.08; animation-duration: 14s; }
.fx-bg-waves > :nth-child(2) { height: 32%; opacity: 0.12; animation-duration: 20s; animation-direction: reverse; }
.fx-bg-waves > :nth-child(3) { height: 40%; opacity: 0.18; animation-duration: 28s; }
/* bokeh: soft circles drifting up */
@keyframes fx-bokeh {
0% { transform: translateY(8cqh); opacity: 0; }
50% { opacity: 1; }
100% { transform: translateY(-20cqh); opacity: 0; }
}
.fx-bg-bokeh > * {
left: calc(var(--x) * 1%);
top: calc(var(--y) * 1%);
width: calc(var(--s) * 1cqmin);
height: calc(var(--s) * 1cqmin);
opacity: 0.6;
background: radial-gradient(closest-side, color-mix(in oklab, var(--primary) 25%, transparent), transparent);
animation: fx-bokeh var(--dur) ease-in-out infinite;
animation-delay: var(--delay);
}
.fx-bg-bokeh > :nth-child(even) {
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 22%, transparent), transparent);
}
/* ---- Off screen covers hold their frame (components/GameCover.jsx) ---- */
.fx-paused,
.fx-paused *,
.fx-paused::before,
.fx-paused::after {
animation-play-state: paused;
}
/* ---- Static mode: the system setting and the "Reduce animations" setting (utils/appearance.js) ---- */
@media (prefers-reduced-motion: reduce) {
.fx, .fx *, .fx::before, .fx::after { animation: none !important; }
}
html[data-reduce-motion] .fx,
html[data-reduce-motion] .fx *,
html[data-reduce-motion] .fx::before,
html[data-reduce-motion] .fx::after {
animation: none !important;
}
+38
View File
@@ -0,0 +1,38 @@
import {useEffect, useState} from 'react';
// One observer for every element, so 80 covers cost one observer and not 80. It lives while something is watched.
const setters = new Map();
let observer = null;
const sharedObserver = () => {
observer ??= new IntersectionObserver((entries) => {
for (const entry of entries) setters.get(entry.target)?.(entry.isIntersecting);
}, {rootMargin: '100px'});
return observer;
};
/**
* Whether the element of `ref` is on screen (or 100 px near it). True while `enabled` is false, and where the
* browser has no IntersectionObserver; it also starts true, so the first paint is not held back.
*/
export default function useInView(ref, enabled) {
const [inView, setInView] = useState(true);
useEffect(() => {
const element = ref.current;
if (!enabled || !element || typeof IntersectionObserver === 'undefined') return undefined;
const watcher = sharedObserver();
setters.set(element, setInView);
watcher.observe(element);
return () => {
setters.delete(element);
watcher.unobserve(element);
if (setters.size === 0) {
watcher.disconnect();
observer = null;
}
};
}, [ref, enabled]);
return !enabled || inView;
}
+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();
+1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./effects.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
+53 -1
View File
@@ -1,10 +1,11 @@
import {Swords, Trophy} from 'lucide-react';
import {apiFetch, request} from './api.js';
import {storage} from './storage.js';
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME'];
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME', 'COMPLETED_EFFECT', 'PAGE_BACKGROUND', 'BANNER_BACKGROUND'];
// The shop catalog of Core (ShopItem) as looks: only these classes are ever applied, never a string from the server.
// An unknown code is skipped.
@@ -17,6 +18,28 @@ export const LOOKS = {
ACCENT_TEAL: {accent: 'teal'},
FRAME_GOLD: {className: 'ring-2 ring-amber-400 ring-offset-2 ring-offset-background'},
FRAME_NEON: {className: 'ring-2 ring-fuchsia-500 ring-offset-2 ring-offset-background shadow-[0_0_14px] shadow-fuchsia-500/60'},
// Completed effects (effects.css): `cover` is the class set of a tile, `detail` the decoration of the details page.
FX_GOLD_SHINE: {cover: 'fx fx-cover-gold-shine outline outline-2 -outline-offset-2 outline-amber-400', detail: 'fx-detail-gold-shine'},
FX_NEON_PULSE: {cover: 'fx fx-cover-neon-pulse outline outline-2 -outline-offset-2 outline-primary', detail: 'fx-detail-neon-pulse'},
FX_HOLO: {cover: 'fx fx-cover-holo', detail: 'fx-detail-holo'},
FX_EMBER: {cover: 'fx fx-cover-ember', detail: 'fx-detail-ember'},
// Page and banner backgrounds: BG_ and BN_ of a name share one engine (components/EffectBackground.jsx).
BG_TRIANGLES: {effect: 'triangles'},
BG_STARFIELD: {effect: 'starfield'},
BG_PIXELS: {effect: 'pixels'},
BG_AURORA: {effect: 'aurora'},
BG_MESH: {effect: 'mesh'},
BG_GRID: {effect: 'grid'},
BG_WAVES: {effect: 'waves'},
BG_BOKEH: {effect: 'bokeh'},
BN_TRIANGLES: {effect: 'triangles'},
BN_STARFIELD: {effect: 'starfield'},
BN_PIXELS: {effect: 'pixels'},
BN_AURORA: {effect: 'aurora'},
BN_MESH: {effect: 'mesh'},
BN_GRID: {effect: 'grid'},
BN_WAVES: {effect: 'waves'},
BN_BOKEH: {effect: 'bokeh'},
};
/** The premium accent worn, or null (then the appearance accent applies). */
@@ -25,12 +48,41 @@ export const equippedAccent = (equipped) => LOOKS[equipped?.ACCENT]?.accent ?? n
/** The classes of the item worn in a slot, or undefined. */
export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className;
// Only a COMPLETED game shows the completed effect (FINISHED is not 100 %).
const completedLook = (equipped, status) => (status === 'COMPLETED' ? LOOKS[equipped?.COMPLETED_EFFECT] : undefined);
/** The cover classes of the completed effect worn, or undefined. */
export const coverEffect = (equipped, status) => completedLook(equipped, status)?.cover;
/** The details page decoration class of the completed effect worn, or undefined. */
export const detailEffect = (equipped, status) => completedLook(equipped, status)?.detail;
/** The effect name of the page background worn, or null (then the game art applies). */
export const backgroundEffect = (equipped) => LOOKS[equipped?.PAGE_BACKGROUND]?.effect ?? null;
/** The effect name of the banner background worn, or null. */
export const bannerEffect = (equipped) => LOOKS[equipped?.BANNER_BACKGROUND]?.effect ?? null;
/** The badge worn: `{code, Icon, className}` or null. */
export const equippedBadge = (equipped) => {
const look = LOOKS[equipped?.BADGE];
return look?.Icon ? {code: equipped.BADGE, ...look} : null;
};
const EQUIPPED_STORAGE_KEY = 'tbp_equipped';
/** The last known worn looks, so the first paint does not flash the item 10 art before the wallet answers. */
export const readCachedEquipped = () => {
try {
const value = JSON.parse(storage.get(EQUIPPED_STORAGE_KEY));
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
} catch {
return {};
}
};
export const cacheEquipped = (equipped) => storage.set(EQUIPPED_STORAGE_KEY, JSON.stringify(equipped));
/** GET /api/rewards/wallet on Core: `{ok, status, data}`. Data is the balance, catalog, owned codes, worn looks and history. */
export const loadWallet = () => request(`${REWARDS_URL}/wallet`);
+26
View File
@@ -0,0 +1,26 @@
import {describe, expect, it} from 'vitest';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, LOOKS} from './rewards.js';
describe('effects worn', () => {
const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'};
it('shows the completed effect only on a COMPLETED game', () => {
expect(coverEffect(equipped, 'COMPLETED')).toContain('fx-cover-gold-shine');
expect(detailEffect(equipped, 'COMPLETED')).toBe('fx-detail-gold-shine');
expect(coverEffect(equipped, 'FINISHED')).toBeUndefined();
expect(detailEffect(equipped, 'FINISHED')).toBeUndefined();
});
it('ignores an unknown or missing code', () => {
expect(coverEffect({COMPLETED_EFFECT: 'FX_FUTURE'}, 'COMPLETED')).toBeUndefined();
expect(coverEffect(undefined, 'COMPLETED')).toBeUndefined();
expect(backgroundEffect({PAGE_BACKGROUND: 'BG_FUTURE'})).toBeNull();
expect(bannerEffect({})).toBeNull();
});
it('maps a background and a banner of one name to the same engine', () => {
expect(backgroundEffect(equipped)).toBe('starfield');
expect(bannerEffect(equipped)).toBe('waves');
expect(LOOKS.BG_GRID.effect).toBe(LOOKS.BN_GRID.effect);
});
});