diff --git a/docs/pr-assets/premium-membership-required.png b/docs/pr-assets/premium-membership-required.png new file mode 100644 index 000000000..85d30ff4d Binary files /dev/null and b/docs/pr-assets/premium-membership-required.png differ diff --git a/docs/pr-assets/premium-membership-unknown.png b/docs/pr-assets/premium-membership-unknown.png new file mode 100644 index 000000000..fd2f82864 Binary files /dev/null and b/docs/pr-assets/premium-membership-unknown.png differ diff --git a/locales/en.json b/locales/en.json index 8fd2acffe..145156d19 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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}}" diff --git a/opennow-stable/src/renderer/src/components/GameDetailModal.tsx b/opennow-stable/src/renderer/src/components/GameDetailModal.tsx index 0ee37e37f..629de5b8a 100644 --- a/opennow-stable/src/renderer/src/components/GameDetailModal.tsx +++ b/opennow-stable/src/renderer/src/components/GameDetailModal.tsx @@ -1,4 +1,4 @@ -import { Check, X } from "lucide-react"; +import { Check, Crown, X } from "lucide-react"; import { useId, useLayoutEffect, @@ -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"; @@ -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 @@ -181,6 +183,18 @@ export function GameDetailModal({
+ {requiredPaidMembershipTier && ( +
+ + + {t("gameDetails.premiumRequired")} + {t("gameDetails.freeTierUnavailable", { tier: requiredPaidMembershipTier })} + +
+ )} + setActiveShot(0), [game.id]); @@ -80,8 +82,19 @@ export function ConsoleGameDetails({ {game.contentRatings?.length ? ( {game.contentRatings[0]} ) : null} + {requiredPaidMembershipTier && ( + + {t("gameDetails.premiumRequired")} + + )}
+ {requiredPaidMembershipTier && ( +

+ {t("gameDetails.freeTierUnavailable", { tier: requiredPaidMembershipTier })} +

+ )} +

{game.longDescription || game.description || game.featureLabels?.join(" / ") || t("library.loadingGameDetails")}

diff --git a/opennow-stable/src/renderer/src/lib/premiumMembership.test.ts b/opennow-stable/src/renderer/src/lib/premiumMembership.test.ts new file mode 100644 index 000000000..62d384bec --- /dev/null +++ b/opennow-stable/src/renderer/src/lib/premiumMembership.test.ts @@ -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); +}); diff --git a/opennow-stable/src/renderer/src/lib/premiumMembership.ts b/opennow-stable/src/renderer/src/lib/premiumMembership.ts new file mode 100644 index 000000000..bf6778934 --- /dev/null +++ b/opennow-stable/src/renderer/src/lib/premiumMembership.ts @@ -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, +): 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; +} diff --git a/opennow-stable/src/renderer/src/styles.css b/opennow-stable/src/renderer/src/styles.css index 5ecb950ae..7b4709059 100644 --- a/opennow-stable/src/renderer/src/styles.css +++ b/opennow-stable/src/renderer/src/styles.css @@ -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; diff --git a/opennow-stable/src/renderer/src/styles/console.css b/opennow-stable/src/renderer/src/styles/console.css index 039aa82ce..ced034f91 100644 --- a/opennow-stable/src/renderer/src/styles/console.css +++ b/opennow-stable/src/renderer/src/styles/console.css @@ -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); @@ -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;