feat(portal): game page with in-place play

Game detail and play are one portal page: a stage with the screenshot
and a Play button (or Enter) that loads the game in place, controls
under it, details, plays, rating, tags, favorite and add to playlist,
and related games.
This commit is contained in:
2026-10-13 11:18:25 -07:00
parent bd47e16138
commit c044f5e9cf
10 changed files with 598 additions and 20 deletions
+3 -1
View File
@@ -144,7 +144,9 @@ canPlayInBrowser, and downloading status.
Query params: `limit` (default: 10, max: 50), `showExtreme` (default: false)
Returns array of related games based on developer/series/tags.
Returns games from the same library ranked by shared descriptive tags (meta,
presence, warning and language tags are ignored; rarer tags weigh more), then
the same series, then the same developer.
## Search Examples
+21
View File
@@ -50,6 +50,27 @@ body text uses Verdana.
See the [Discovery API](../06-api-reference/discovery-api.md).
## Game Page
Game detail and play are one page (`views/GamePage.tsx`); the `/play` routes
open the same page with the game already started. Shared playlist links keep the
application's detail and player views.
- **Stage:** the game's screenshot with its logo and a Play button (or Enter).
Playing loads the game in place, starting a play session and fetching the game
data if needed. Saves, the Ruffle / original Flash Player switch and
Fullscreen sit under the game; in fullscreen the player's own header bar
returns. Platforms that can't run in the browser or be streamed point to the
Flashpoint Launcher.
- **About this game:** description, notes, and release date, developer,
publisher, series, platform, play mode and language; developer, publisher and
series link to browse.
- **Side boxes:** the signed-in user's plays of this game, the rating, and tags
linking to browse.
- **Favorite** and **Add to playlist** for signed-in users.
- **More like this:**
[related games](../06-api-reference/games-api.md#get-related-games).
## Default Ordering
Browse lists default to a shuffle instead of title order. The shuffle is seeded
+11 -10
View File
@@ -53,6 +53,7 @@ const AnimationsView = lazy(() =>
const GameDetailView = lazy(() =>
import('./views/GameDetailView').then((m) => ({ default: m.GameDetailView }))
);
const GamePage = lazy(() => import('./views/GamePage').then((m) => ({ default: m.GamePage })));
const GamePlayerView = lazy(() =>
import('./views/GamePlayerView').then((m) => ({ default: m.GamePlayerView }))
);
@@ -350,7 +351,7 @@ function App() {
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GameDetailView />
<GamePage />
</Suspense>
</ProtectedRoute>
}
@@ -360,7 +361,7 @@ function App() {
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePlayerView />
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
@@ -370,7 +371,7 @@ function App() {
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GameDetailView />
<GamePage />
</Suspense>
</ProtectedRoute>
}
@@ -380,7 +381,7 @@ function App() {
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePlayerView />
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
@@ -390,7 +391,7 @@ function App() {
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GameDetailView />
<GamePage />
</Suspense>
</ProtectedRoute>
}
@@ -400,7 +401,7 @@ function App() {
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePlayerView />
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
@@ -410,7 +411,7 @@ function App() {
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GameDetailView />
<GamePage />
</Suspense>
</ProtectedRoute>
}
@@ -420,7 +421,7 @@ function App() {
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePlayerView />
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
@@ -432,7 +433,7 @@ function App() {
element={
<ProtectedRoute requireAuth={false} allowSharedAccess={true}>
<Suspense fallback={<RouteLoadingFallback />}>
<GameDetailView />
<GamePage />
</Suspense>
</ProtectedRoute>
}
@@ -444,7 +445,7 @@ function App() {
element={
<ProtectedRoute requirePermission="games.play">
<Suspense fallback={<RouteLoadingFallback />}>
<GamePlayerView />
<GamePage autoPlay />
</Suspense>
</ProtectedRoute>
}
+26 -2
View File
@@ -1,4 +1,4 @@
import { Fragment, useState, useEffect, useRef, useMemo } from 'react';
import { Fragment, useState, useEffect, useRef, useMemo, type ReactNode } from 'react';
import { logger } from '@/lib/logger';
import {
AlertCircle,
@@ -18,6 +18,18 @@ import { useAuthStore } from '@/store/auth';
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
import { PlatformIcon } from '@/components/ui/platform-icon';
/** Player actions, for pages that place the controls themselves */
export interface PlayerControls {
isFullscreen: boolean;
toggleFullscreen: () => void;
/** Whether the original Flash Player is in use instead of Ruffle */
preferStream: boolean;
/** Present when the game can switch between Ruffle and the original Flash Player */
togglePlayer?: () => void;
/** Present for signed-in players, whose saves are kept on their account */
openSaves?: () => void;
}
export interface GamePlayerProps {
/** Required to stream games that can't run in a browser */
gameId?: string;
@@ -41,6 +53,8 @@ export interface GamePlayerProps {
developer?: string;
/** Callback for back button (shown in fullscreen mode) */
onBack?: () => void;
/** Renders the controls below the game instead of the header bar, outside fullscreen */
renderControls?: (controls: PlayerControls) => ReactNode;
}
/**
@@ -66,6 +80,7 @@ function GamePlayerInternal({
logoUrl,
developer,
onBack,
renderControls,
}: GamePlayerProps) {
const [isFullscreen, setIsFullscreen] = useState(initialFullscreen);
// Streams a game Ruffle can play with the original player, for when Ruffle falls short
@@ -181,7 +196,7 @@ function GamePlayerInternal({
return (
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
{showControls ? (
{showControls && (isFullscreen || !renderControls) ? (
<div
className={`flex items-center justify-between bg-card/90 backdrop-blur-sm flex-shrink-0 border-b border-border ${
isFullscreen ? 'px-4 py-3' : 'px-6 py-4'
@@ -421,6 +436,15 @@ function GamePlayerInternal({
</Fragment>
)}
</div>
{renderControls && !isFullscreen
? renderControls({
isFullscreen,
toggleFullscreen,
preferStream,
togglePlayer: canSwitchPlayer ? () => setPreferStream((prev) => !prev) : undefined,
openSaves: isSignedIn && gameId ? () => setSavesOpen(true) : undefined,
})
: null}
{isSignedIn && gameId ? (
<SavesDialog
gameId={gameId}
+1 -1
View File
@@ -1,6 +1,6 @@
export { RufflePlayer } from './RufflePlayer';
export type { RufflePlayerProps } from './RufflePlayer';
export { GamePlayer } from './GamePlayer';
export type { GamePlayerProps } from './GamePlayer';
export type { GamePlayerProps, PlayerControls } from './GamePlayer';
export { StreamPlayer } from './StreamPlayer';
export type { StreamPlayerProps } from './StreamPlayer';
+4 -1
View File
@@ -2,7 +2,8 @@ import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface PortalBoxProps {
title: ReactNode;
/** Omit for a plain outlined box */
title?: ReactNode;
action?: ReactNode;
className?: string;
children: ReactNode;
@@ -12,10 +13,12 @@ interface PortalBoxProps {
export function PortalBox({ title, action, className, children }: PortalBoxProps) {
return (
<section className={cn('portal-box', className)}>
{title ? (
<div className="portal-box-title">
<h2>{title}</h2>
{action}
</div>
) : null}
{children}
</section>
);
@@ -0,0 +1,53 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GameStage } from './GameStage';
import type { Game } from '@/types/game';
vi.mock('@/components/player', () => ({
GamePlayer: ({ title }: { title: string }) => <div>playing {title}</div>,
}));
vi.mock('@/hooks/usePlayTracking', () => ({ usePlaySession: vi.fn() }));
vi.mock('@/hooks/useGames', () => ({
useGameLaunchData: () => ({
data: { platform: 'Flash', contentUrl: '/game.swf', canPlayInBrowser: true },
isLoading: false,
error: null,
}),
}));
const game = {
id: '1443f6b9-6bec-47cb-873e-72b9aedefccb',
title: '3D Logic',
developer: 'Alex Mateev',
publisher: 'Microgames',
platformName: 'Flash',
library: 'arcade',
orderTitle: '3d logic',
} satisfies Game;
describe('GameStage', () => {
it('waits for Play before loading the game', async () => {
render(<GameStage game={game} autoPlay={false} />);
expect(screen.queryByText('playing 3D Logic')).not.toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'PLAY' }));
expect(screen.getByText('playing 3D Logic')).toBeInTheDocument();
});
it('starts on Enter, and right away from a play link', async () => {
const { unmount } = render(<GameStage game={game} autoPlay={false} />);
await userEvent.keyboard('{Enter}');
expect(screen.getByText('playing 3D Logic')).toBeInTheDocument();
unmount();
render(<GameStage game={game} autoPlay />);
expect(screen.getByText('playing 3D Logic')).toBeInTheDocument();
});
it('points launcher-only games to the Flashpoint Launcher', () => {
render(<GameStage game={{ ...game, platformName: 'Unity' }} autoPlay />);
expect(screen.queryByRole('button', { name: 'PLAY' })).not.toBeInTheDocument();
expect(screen.getByText(/only be played in the Flashpoint Launcher/)).toBeInTheDocument();
});
});
@@ -0,0 +1,176 @@
import { useEffect, useRef, useState } from 'react';
import { History, Loader2, Maximize2, MonitorPlay } from 'lucide-react';
import { GamePlayer, type PlayerControls } from '@/components/player';
import { useGameLaunchData } from '@/hooks/useGames';
import { usePlaySession } from '@/hooks/usePlayTracking';
import { getGameLogoUrl, getGameScreenshotUrl } from '@/utils/gameUtils';
import type { Game } from '@/types/game';
const STAGE_HEIGHT = 'min(690px, calc(100vh - 140px))';
function canPlay(game: Game): boolean {
return game.platformName === 'Flash' || game.platformName === 'HTML5' || Boolean(game.streamable);
}
function howItPlays(game: Game): string {
if (game.platformName === 'Flash') return 'Plays in your browser with Ruffle';
if (game.platformName === 'HTML5') return 'Plays in your browser';
return `${game.platformName ?? 'This game'} streams from the server`;
}
function isTyping(target: EventTarget | null): boolean {
return (
target instanceof HTMLElement &&
(target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(target.tagName))
);
}
interface GameStageProps {
game: Game;
autoPlay: boolean;
}
/** The game's screenshot with a Play button, replaced by the game itself once started */
export function GameStage({ game, autoPlay }: GameStageProps) {
const playable = canPlay(game);
const [playing, setPlaying] = useState(autoPlay && playable);
// Enter starts the game, as on the portals of old
const playingRef = useRef(playing);
useEffect(() => {
playingRef.current = playing;
}, [playing]);
useEffect(() => {
if (!playable) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Enter' || playingRef.current || isTyping(event.target)) return;
event.preventDefault();
setPlaying(true);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [playable]);
if (!playing) {
return (
<div className="portal-stage" style={{ height: STAGE_HEIGHT }}>
<img
src={getGameScreenshotUrl(game.id)}
alt=""
className="portal-stage-backdrop"
onError={(event) => event.currentTarget.remove()}
/>
<div className="portal-stage-splash">
<span className="portal-tile-art">
<img
src={getGameLogoUrl(game.id)}
alt=""
onError={(event) => event.currentTarget.removeAttribute('src')}
/>
</span>
{playable ? (
<>
<button type="button" className="portal-play w-64" onClick={() => setPlaying(true)}>
PLAY
</button>
<p className="text-[color:var(--p-meta)]">
{howItPlays(game)} · or press <kbd className="portal-kbd">Enter</kbd>
</p>
</>
) : (
<p className="portal-display max-w-md text-center text-lg">
{game.platformName ?? 'This game'} games can only be played in the Flashpoint
Launcher.
</p>
)}
</div>
</div>
);
}
return <PlayingStage game={game} />;
}
function PlayingStage({ game }: { game: Game }) {
const { data: launchData, isLoading, error } = useGameLaunchData(game.id);
usePlaySession(game.id, game.title);
if (isLoading || launchData?.downloading) {
return (
<div className="portal-stage" style={{ height: STAGE_HEIGHT }}>
<div className="portal-stage-splash">
<Loader2 size={32} className="animate-spin" aria-hidden="true" />
<p className="portal-display text-lg">
{launchData?.downloading ? 'Fetching the game...' : 'Loading...'}
</p>
</div>
</div>
);
}
if (error || !launchData) {
return (
<div className="portal-stage" style={{ height: STAGE_HEIGHT }}>
<div className="portal-stage-splash">
<p className="portal-display text-lg">This game failed to load.</p>
<p className="text-[color:var(--p-meta)]">{error?.message}</p>
</div>
</div>
);
}
return (
<GamePlayer
gameId={game.id}
title={game.title}
platform={launchData.platform ?? game.platformName ?? ''}
contentUrl={launchData.contentUrl}
launchCommand={launchData.launchCommand}
canPlayInBrowser={launchData.canPlayInBrowser}
streamable={launchData.streamable ?? false}
logoUrl={getGameLogoUrl(game.id)}
developer={game.developer}
height={STAGE_HEIGHT}
className="portal-stage-player"
renderControls={(controls) => <StageToolbar {...controls} />}
/>
);
}
function StageToolbar({ toggleFullscreen, preferStream, togglePlayer, openSaves }: PlayerControls) {
return (
<div className="flex justify-end gap-2 pt-2.5">
{openSaves ? (
<button
type="button"
className="portal-capsule portal-capsule-sm"
onClick={openSaves}
title="Saves kept on your account"
>
<History size={14} aria-hidden="true" />
Saves
</button>
) : null}
{togglePlayer ? (
<button
type="button"
className="portal-capsule portal-capsule-sm"
onClick={togglePlayer}
title={
preferStream
? 'Play in the browser with Ruffle'
: 'Play with the original Flash Player, streamed from the server'
}
>
<MonitorPlay size={14} aria-hidden="true" />
{preferStream ? 'Use Ruffle' : 'Original Flash Player'}
</button>
) : null}
<button type="button" className="portal-capsule portal-capsule-sm" onClick={toggleFullscreen}>
<Maximize2 size={14} aria-hidden="true" />
Fullscreen
</button>
</div>
);
}
+29 -1
View File
@@ -123,7 +123,8 @@ body.portal {
outline: none;
}
.portal-capsule[aria-current='page'] {
.portal-capsule[aria-current='page'],
.portal-capsule[aria-pressed='true'] {
background: linear-gradient(180deg, var(--p-highlight), var(--p-box));
color: var(--p-header-ink);
border-color: var(--p-chrome-1);
@@ -321,3 +322,30 @@ body.portal {
.portal-meta-row svg {
flex: none;
}
.portal-stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #000;
}
.portal-stage-backdrop {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(3px) brightness(0.4);
transform: scale(1.04);
}
.portal-stage-splash {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
+270
View File
@@ -0,0 +1,270 @@
import { useMemo, useState } from 'react';
import { Link, useLocation, useParams } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import {
BookOpen,
Building2,
CalendarDays,
Gamepad2,
Heart,
Languages,
ListPlus,
Users,
Wrench,
type LucideIcon,
} from 'lucide-react';
import { toast } from 'sonner';
import { GameRatingSection } from '@/components/game/GameRatingSection';
import { AddToPlaylistModal } from '@/components/playlist/AddToPlaylistModal';
import { GameTile } from '@/components/portal/GameTile';
import { PortalBox } from '@/components/portal/PortalBox';
import { GameStage } from '@/components/portal/game/GameStage';
import { Skeleton } from '@/components/ui/skeleton';
import { useFavoriteGameIds, useToggleFavorite } from '@/hooks/useFavorites';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
import { useGame, useRelatedGames } from '@/hooks/useGames';
import { useGameStats } from '@/hooks/usePlayTracking';
import { formatDuration } from '@/lib/cron-utils';
import { buildFilterSearchParams, type FilterUrlParams } from '@/lib/filterUrlCompression';
import { useAuthStore } from '@/store/auth';
import { getErrorMessage } from '@/types/api-error';
import type { BreadcrumbContext } from '@/components/common/Breadcrumbs';
function browseLink(params: FilterUrlParams): string {
return `/browse?${buildFilterSearchParams(params).toString()}`;
}
interface MetaRowProps {
icon: LucideIcon;
label: string;
value?: string;
href?: string;
}
function MetaRow({ icon: Icon, label, value, href }: MetaRowProps) {
if (!value) return null;
return (
<div className="portal-meta-row normal-case">
<Icon size={15} aria-hidden="true" />
<span className="w-24 flex-none uppercase text-[color:var(--p-meta)]">{label}</span>
{href ? (
<Link to={href} className="truncate hover:underline">
{value}
</Link>
) : (
<span className="truncate">{value}</span>
)}
</div>
);
}
/** A game's page: the game itself, what it is, and where to go next */
export function GamePage({ autoPlay = false }: { autoPlay?: boolean }) {
const { id = '' } = useParams<{ id: string }>();
const location = useLocation();
const from = (location.state as { breadcrumbContext?: BreadcrumbContext } | null)
?.breadcrumbContext;
const { data: game, isLoading, error } = useGame(id);
const { data: related } = useRelatedGames(id, 7);
if (isLoading) {
return (
<div className="space-y-[18px]">
<Skeleton className="h-10 w-96" />
<Skeleton className="h-[600px] w-full" />
</div>
);
}
if (error || !game) {
return (
<PortalBox title="Game not found">
<p>
This game isn&apos;t in the archive. <Link to="/">Back to the homepage</Link>
</p>
</PortalBox>
);
}
const tags = (game.tagsStr ?? '')
.split(';')
.map((tag) => tag.trim())
.filter(Boolean);
return (
<div className="space-y-[18px]">
<div className="flex items-end justify-between gap-6">
<div className="min-w-0">
{from ? (
<Link
to={from.href}
className="text-xs uppercase tracking-wider text-[color:var(--p-meta)] hover:underline"
>
← {from.label}
</Link>
) : null}
<h1 className="portal-display truncate text-[32px] font-semibold leading-tight">
{game.title}
</h1>
{game.developer ? (
<p className="text-[color:var(--p-meta)]">by {game.developer}</p>
) : null}
</div>
<GameActions gameId={game.id} title={game.title} />
</div>
<PortalBox>
<GameStage key={game.id} game={game} autoPlay={autoPlay} />
</PortalBox>
<div className="grid grid-cols-[minmax(0,1fr)_360px] gap-[18px]">
<PortalBox title="About this game">
{game.originalDescription ? (
<p className="mb-3 whitespace-pre-line leading-relaxed">{game.originalDescription}</p>
) : null}
{game.notes ? (
<p className="mb-3 whitespace-pre-line text-[color:var(--p-meta)]">{game.notes}</p>
) : null}
<div className="grid grid-cols-[minmax(0,1fr)] gap-1.5">
<MetaRow icon={CalendarDays} label="Released" value={game.releaseDate} />
<MetaRow
icon={Wrench}
label="Developer"
value={game.developer}
href={browseLink({ developers: game.developer, fo: 'Developer' })}
/>
<MetaRow
icon={Building2}
label="Publisher"
value={game.publisher}
href={browseLink({ publishers: game.publisher, fo: 'Publisher' })}
/>
<MetaRow
icon={BookOpen}
label="Series"
value={game.series}
href={browseLink({ series: game.series, fo: 'Series' })}
/>
<MetaRow icon={Gamepad2} label="Platform" value={game.platformName} />
<MetaRow icon={Users} label="Play mode" value={game.playMode} />
<MetaRow icon={Languages} label="Language" value={game.language} />
</div>
</PortalBox>
<div className="space-y-[18px]">
<YourPlays gameId={game.id} />
<RatingBox gameId={game.id} />
{tags.length > 0 ? (
<PortalBox title="Tags">
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<Link
key={tag}
to={browseLink({ tags: tag, fo: 'Tag' })}
className="portal-capsule portal-capsule-sm"
>
{tag}
</Link>
))}
</div>
</PortalBox>
) : null}
</div>
</div>
{related && related.length > 0 ? (
<PortalBox title="More like this">
<div className="portal-tiles">
{related.map((relatedGame) => (
<GameTile key={relatedGame.id} id={relatedGame.id} title={relatedGame.title} />
))}
</div>
</PortalBox>
) : null}
</div>
);
}
function GameActions({ gameId, title }: { gameId: string; title: string }) {
const signedIn = useAuthStore((state) => state.isAuthenticated && !state.isGuest);
const { enableFavorites, enablePlaylists } = useFeatureFlags();
const { data: favoriteIds } = useFavoriteGameIds();
const toggleFavorite = useToggleFavorite();
const [addingToPlaylist, setAddingToPlaylist] = useState(false);
const isFavorite = useMemo(() => favoriteIds?.includes(gameId) ?? false, [favoriteIds, gameId]);
if (!signedIn) return null;
const handleFavorite = () => {
toggleFavorite.mutate(gameId, {
onError: (error: unknown) => {
toast.error(getErrorMessage(error) || 'Could not update favorites');
},
});
};
return (
<div className="flex flex-none gap-2">
{enableFavorites ? (
<button
type="button"
className="portal-capsule portal-capsule-sm"
aria-pressed={isFavorite}
onClick={handleFavorite}
disabled={toggleFavorite.isPending}
>
<Heart size={14} fill={isFavorite ? 'currentColor' : 'none'} aria-hidden="true" />
{isFavorite ? 'Favorited' : 'Favorite'}
</button>
) : null}
{enablePlaylists ? (
<>
<button
type="button"
className="portal-capsule portal-capsule-sm"
onClick={() => setAddingToPlaylist(true)}
>
<ListPlus size={14} aria-hidden="true" />
Add to playlist
</button>
<AddToPlaylistModal
isOpen={addingToPlaylist}
onClose={() => setAddingToPlaylist(false)}
gameId={gameId}
gameTitle={title}
/>
</>
) : null}
</div>
);
}
function YourPlays({ gameId }: { gameId: string }) {
const { data } = useGameStats();
const stats = data?.data.find((entry) => entry.gameId === gameId);
if (!stats) return null;
return (
<PortalBox title="Your plays">
<p>
Played {stats.totalPlays} {stats.totalPlays === 1 ? 'time' : 'times'}
{stats.totalPlaytimeSeconds > 0
? `, ${formatDuration(stats.totalPlaytimeSeconds)} in all`
: ''}
</p>
<p className="text-[color:var(--p-meta)]">
Last played {formatDistanceToNow(new Date(stats.lastPlayedAt), { addSuffix: true })}
</p>
</PortalBox>
);
}
function RatingBox({ gameId }: { gameId: string }) {
const { enableRatings } = useFeatureFlags();
if (!enableRatings) return null;
return (
<PortalBox title="Rating">
<GameRatingSection gameId={gameId} className="mt-0 sm:flex-col sm:items-start sm:gap-2" />
</PortalBox>
);
}