Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/pr-assets/premium-membership-required.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-assets/premium-membership-unknown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@
"gameDetails": {
"eyebrow": "Game details",
"details": "About this game",
"premiumRequired": "Premium membership required",
"freeTierUnavailable": "Not playable on the Free tier. Requires {{tier}} or higher on GeForce NOW.",
"notOwned": "Not owned",
"playOn": "Play on {{store}}",
"storeOption": "{{store}}, {{ownership}}"
Expand Down
16 changes: 15 additions & 1 deletion opennow-stable/src/renderer/src/components/GameDetailModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Check, X } from "lucide-react";
import { Check, Crown, X } from "lucide-react";
import {
useId,
useLayoutEffect,
Expand All @@ -9,6 +9,7 @@ import {
import type { GameInfo } from "@shared/gfn";
import { getControllerHeroBackgroundCandidates, getPlayerSummary } from "../lib/controllerCatalogUi";
import { getActiveStoreOption, getStoreOptions } from "../lib/gameCardStores";
import { getRequiredPaidMembershipTier } from "../lib/premiumMembership";
import { useTranslation } from "../i18n";
import { getStoreDisplayName, getStoreIconComponent } from "./GameCard";
import { ModalSurface } from "./ui/ModalSurface";
Expand Down Expand Up @@ -126,6 +127,7 @@ export function GameDetailModal({

const storeOptions = getStoreOptions(game, selectedVariantId);
const activeStoreOption = getActiveStoreOption(storeOptions);
const requiredPaidMembershipTier = getRequiredPaidMembershipTier(game);
const playerSummary = getPlayerSummary(game);
const description = game.description
|| game.longDescription
Expand Down Expand Up @@ -181,6 +183,18 @@ export function GameDetailModal({
</header>

<div className="game-detail-body">
{requiredPaidMembershipTier && (
<div className="game-detail-membership-warning" role="note">
<span className="game-detail-membership-icon" aria-hidden="true">
<Crown size={18} strokeWidth={2.2} />
</span>
<span>
<strong>{t("gameDetails.premiumRequired")}</strong>
<span>{t("gameDetails.freeTierUnavailable", { tier: requiredPaidMembershipTier })}</span>
</span>
</div>
)}

<ExpandableDescription
key={game.id}
descriptionId={descriptionId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "../../lib/controllerCatalogUi";
import { getGameScreenshots } from "../../lib/consoleGameMedia";
import { withImageWidth } from "../../lib/consoleImageSizing";
import { getRequiredPaidMembershipTier } from "../../lib/premiumMembership";
import { useConsoleImageWidths } from "../../hooks/useConsoleImageWidths";
import { useTranslation } from "../../i18n";

Expand Down Expand Up @@ -50,6 +51,7 @@ export function ConsoleGameDetails({
const screenshots = getGameScreenshots(game);
const players = getPlayerSummary(game);
const genre = getPrimaryGenre(game);
const requiredPaidMembershipTier = getRequiredPaidMembershipTier(game);

const [activeShot, setActiveShot] = useState(0);
useEffect(() => setActiveShot(0), [game.id]);
Expand Down Expand Up @@ -80,8 +82,19 @@ export function ConsoleGameDetails({
{game.contentRatings?.length ? (
<span className="console-billboard-meta-chip">{game.contentRatings[0]}</span>
) : null}
{requiredPaidMembershipTier && (
<span className="console-billboard-meta-chip console-billboard-meta-chip--premium">
{t("gameDetails.premiumRequired")}
</span>
)}
</div>

{requiredPaidMembershipTier && (
<p className="console-details-membership-note">
{t("gameDetails.freeTierUnavailable", { tier: requiredPaidMembershipTier })}
</p>
)}

<p className="console-details-description">
{game.longDescription || game.description || game.featureLabels?.join(" / ") || t("library.loadingGameDetails")}
</p>
Expand Down
17 changes: 17 additions & 0 deletions opennow-stable/src/renderer/src/lib/premiumMembership.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import assert from "node:assert/strict";
import test from "node:test";

import { getRequiredPaidMembershipTier } from "./premiumMembership";

test("detects known paid GFN membership requirements", () => {
assert.equal(getRequiredPaidMembershipTier({ membershipTierLabel: "Performance" }), "Performance");
assert.equal(getRequiredPaidMembershipTier({ membershipTierLabel: "Ultimate membership" }), "Ultimate membership");
assert.equal(getRequiredPaidMembershipTier({ membershipTierLabel: " premium " }), "premium");
});

test("does not claim a restriction for free, missing, or unknown tier metadata", () => {
assert.equal(getRequiredPaidMembershipTier({ membershipTierLabel: "Free" }), null);
assert.equal(getRequiredPaidMembershipTier({ membershipTierLabel: "" }), null);
assert.equal(getRequiredPaidMembershipTier({}), null);
assert.equal(getRequiredPaidMembershipTier({ membershipTierLabel: "Special Access" }), null);
});
24 changes: 24 additions & 0 deletions opennow-stable/src/renderer/src/lib/premiumMembership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { GameInfo } from "@shared/gfn";

const PAID_MEMBERSHIP_TIERS = new Set([
"FOUNDERS",
"PERFORMANCE",
"PREMIUM",
"PRIORITY",
"ULTIMATE",
]);

export function getRequiredPaidMembershipTier(
game: Pick<GameInfo, "membershipTierLabel">,
): string | null {
const label = game.membershipTierLabel?.trim();
if (!label) return null;

const tokens = label
.toUpperCase()
.replace(/[^A-Z0-9]+/g, " ")
.trim()
.split(" ");

return tokens.some((token) => PAID_MEMBERSHIP_TIERS.has(token)) ? label : null;
}
40 changes: 40 additions & 0 deletions opennow-stable/src/renderer/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -3301,6 +3301,46 @@ button.game-card-store-chip.owned.active:hover {
padding: 18px 26px 24px;
}

.game-detail-membership-warning {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--warning) 42%, var(--panel-border));
border-radius: var(--r-sm);
background: color-mix(in srgb, var(--warning) 10%, var(--chip));
color: var(--ink);
}

.game-detail-membership-icon {
display: grid;
flex: 0 0 auto;
width: 34px;
height: 34px;
place-items: center;
border-radius: 50%;
background: color-mix(in srgb, var(--warning) 18%, transparent);
color: var(--warning);
}

.game-detail-membership-warning > span:last-child {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}

.game-detail-membership-warning strong {
font-size: var(--text-sm);
line-height: 1.3;
}

.game-detail-membership-warning > span:last-child > span {
color: var(--ink-soft);
font-size: 0.76rem;
line-height: 1.4;
}

.game-detail-description-block {
display: flex;
flex-direction: column;
Expand Down
15 changes: 15 additions & 0 deletions opennow-stable/src/renderer/src/styles/console.css
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,12 @@
color: var(--ink);
}

.console-billboard-meta-chip--premium {
background: color-mix(in srgb, var(--warning) 16%, transparent);
border-color: color-mix(in srgb, var(--warning) 45%, transparent);
color: color-mix(in srgb, var(--warning) 78%, #fff);
}

.console-billboard-description {
margin: 0;
font-size: calc(var(--console-u) * 1);
Expand Down Expand Up @@ -978,6 +984,15 @@
overflow: hidden;
}

.console-details-membership-note {
margin: calc(var(--console-u) * -0.45) 0 0;
max-width: 64ch;
color: color-mix(in srgb, var(--warning) 78%, #fff);
font-size: calc(var(--console-u) * 0.78);
font-weight: 650;
line-height: 1.45;
}

.console-details-actions {
display: flex;
flex-wrap: wrap;
Expand Down
Loading