diff --git a/resources/lang/en.json b/resources/lang/en.json index 4b73ef1bf..4c990d3c8 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -868,13 +868,15 @@ "show_only_owned": "My Skins", "all_owned": "All skins owned! Check back later for new items.", "not_logged_in": "Not logged in", - "blocked": { - "login": "You must be logged in to access this skin.", - "purchase": "Purchase this skin to unlock it." - }, "pattern": { "default": "Default" }, + "try_me": "Try me!", + "trial_remaining": "remaining", + "trial_granted": "Skin trial granted!", + "trial_cooldown": "Only one trial per 24 hours. Please try again later.", + "reward_countdown": "Reward in {seconds} seconds...", + "steam_wishlist_prompt": "Support OpenFront by adding it to your Steam wishlist", "select_skin": "Select Skin", "selected": "selected" }, diff --git a/src/client/Api.ts b/src/client/Api.ts index d128ad2f2..336385a5f 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -125,6 +125,31 @@ export async function createCheckoutSession( } } +export async function grantTemporaryFlare(flare: string): Promise { + try { + const response = await fetch(`${getApiBase()}/flares_granted/temporary`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: await getAuthHeader(), + }, + body: JSON.stringify({ flare }), + }); + if (!response.ok) { + console.error( + "grantTemporaryFlare: request failed", + response.status, + response.statusText, + ); + return false; + } + return true; + } catch (e) { + console.error("grantTemporaryFlare: request failed", e); + return false; + } +} + export function getApiBase() { const domainname = getAudience(); diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index 0f368175c..4bf764032 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -5,7 +5,15 @@ import { CosmeticsSchema, Pattern, } from "../core/CosmeticSchemas"; -import { createCheckoutSession, getApiBase } from "./Api"; +import { + PlayerCosmeticRefs, + PlayerCosmetics, + PlayerPattern, +} from "../core/Schemas"; +import { UserSettings } from "../core/game/UserSettings"; +import { createCheckoutSession, getApiBase, getUserMe } from "./Api"; + +export const TEMP_FLARE_OFFSET = 1 * 60 * 1000; // 1 minute export async function handlePurchase( pattern: Pattern, @@ -77,14 +85,19 @@ export async function getCosmeticsHash(): Promise { return __cosmeticsHash; } +// When a number is returned it signifies when the pattern expires. export function patternRelationship( pattern: Pattern, colorPalette: { name: string; isArchived?: boolean } | null, userMeResponse: UserMeResponse | false, affiliateCode: string | null, -): "owned" | "purchasable" | "blocked" { +): "owned" | "purchasable" | "purchasable_no_trial" | "blocked" | number { const flares = userMeResponse === false ? [] : (userMeResponse.player.flares ?? []); + const expirations: Record = + userMeResponse === false + ? {} + : (userMeResponse.player.flareExpiration ?? {}); if (flares.includes("pattern:*")) { return "owned"; } @@ -100,6 +113,14 @@ export function patternRelationship( const requiredFlare = `pattern:${pattern.name}:${colorPalette.name}`; if (flares.includes(requiredFlare)) { + const expiresAt = expirations[requiredFlare]; + if (expiresAt) { + if (expiresAt - Date.now() <= TEMP_FLARE_OFFSET) { + // Already expired or about to expire so just show it as purchasable. + return "purchasable"; + } + return expiresAt; + } return "owned"; } @@ -118,6 +139,80 @@ export function patternRelationship( return "blocked"; } - // Patterns is for sale, and it's the right store to show it on. + // --- Patterns is for sale, and it's the right store to show it on. --- + + if (pattern.name === "custom") { + // Don't allow trying a custom pattern. + return "purchasable_no_trial"; + } return "purchasable"; } + +export async function getPlayerCosmeticsRefs(): Promise { + const userSettings = new UserSettings(); + const cosmetics = await fetchCosmetics(); + let pattern: PlayerPattern | null = + userSettings.getSelectedPatternName(cosmetics); + + if (pattern) { + const userMe = await getUserMe(); + if (userMe) { + const flareName = + pattern.colorPalette?.name === undefined + ? `pattern:${pattern.name}` + : `pattern:${pattern.name}:${pattern.colorPalette.name}`; + const flares = userMe.player.flares ?? []; + const expirations = userMe.player.flareExpiration ?? {}; + const hasWildcard = flares.includes("pattern:*"); + if (!hasWildcard) { + if (!flares.includes(flareName)) { + pattern = null; + } else if (expirations[flareName]) { + if (expirations[flareName]! - Date.now() <= TEMP_FLARE_OFFSET) { + pattern = null; + } + } + } + } + if (pattern === null) { + userSettings.setSelectedPatternName(undefined); + } + } + + return { + flag: userSettings.getFlag(), + color: userSettings.getSelectedColor() ?? undefined, + patternName: pattern?.name ?? undefined, + patternColorPaletteName: pattern?.colorPalette?.name ?? undefined, + }; +} + +export async function getPlayerCosmetics(): Promise { + const refs = await getPlayerCosmeticsRefs(); + const cosmetics = await fetchCosmetics(); + + const result: PlayerCosmetics = {}; + + if (refs.flag) { + result.flag = refs.flag; + } + + if (refs.color) { + result.color = { color: refs.color }; + } + + if (refs.patternName && cosmetics) { + const pattern = cosmetics.patterns[refs.patternName]; + if (pattern) { + result.pattern = { + name: refs.patternName, + patternData: pattern.pattern, + colorPalette: refs.patternColorPaletteName + ? cosmetics.colorPalettes?.[refs.patternColorPaletteName] + : undefined, + }; + } + } + + return result; +} diff --git a/src/client/Main.ts b/src/client/Main.ts index 9189efebf..7d4812b42 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -10,7 +10,7 @@ import "./AccountModal"; import { getUserMe } from "./Api"; import { userAuth } from "./Auth"; import { joinLobby } from "./ClientGameRunner"; -import { fetchCosmetics } from "./Cosmetics"; +import { getPlayerCosmeticsRefs } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import "./FlagInput"; import { FlagInput } from "./FlagInput"; @@ -182,6 +182,12 @@ declare global { onPlayerReady: (() => void) | null; addUnits: (units: Array<{ type: string }>) => Promise; displayUnits: () => void; + // Rewarded video ad methods + manuallyCreateRewardUi?: (options: { + skipConfirmation?: boolean; + watchAdId?: string; + closeId?: string; + }) => Promise | void; }; Bolt: { on: (unitType: string, event: string, callback: () => void) => void; @@ -761,24 +767,12 @@ class Client { const config = await getServerConfigFromClient(); this.updateJoinUrlForShare(lobby.gameID, config); - const pattern = this.userSettings.getSelectedPatternName( - await fetchCosmetics(), - ); - this.gameStop = joinLobby( this.eventBus, { gameID: lobby.gameID, serverConfig: config, - cosmetics: { - color: this.userSettings.getSelectedColor() ?? undefined, - patternName: pattern?.name ?? undefined, - patternColorPaletteName: pattern?.colorPalette?.name ?? undefined, - flag: - this.flagInput === null || this.flagInput.getCurrentFlag() === "xx" - ? "" - : this.flagInput.getCurrentFlag(), - }, + cosmetics: await getPlayerCosmeticsRefs(), turnstileToken: await this.getTurnstileToken(lobby), playerName: this.usernameInput?.getCurrentUsername() ?? genAnonUsername(), diff --git a/src/client/PatternInput.ts b/src/client/PatternInput.ts index 483bef75c..edd9fc6f6 100644 --- a/src/client/PatternInput.ts +++ b/src/client/PatternInput.ts @@ -1,10 +1,8 @@ import { LitElement, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; -import { Cosmetics } from "../core/CosmeticSchemas"; -import { UserSettings } from "../core/game/UserSettings"; import { PlayerPattern } from "../core/Schemas"; import { renderPatternPreview } from "./components/PatternButton"; -import { fetchCosmetics } from "./Cosmetics"; +import { getPlayerCosmetics } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; import { translateText } from "./Utils"; @@ -17,24 +15,14 @@ export class PatternInput extends LitElement { @property({ type: Boolean, attribute: "show-select-label" }) public showSelectLabel: boolean = false; - private userSettings = new UserSettings(); - private cosmetics: Cosmetics | null = null; private _abortController: AbortController | null = null; - private _onPatternSelected = () => { - this.updateFromSettings(); + private _onPatternSelected = async () => { + const cosmetics = await getPlayerCosmetics(); + this.selectedColor = cosmetics.color?.color ?? null; + this.pattern = cosmetics.pattern ?? null; }; - private updateFromSettings() { - this.selectedColor = this.userSettings.getSelectedColor() ?? null; - - if (this.cosmetics) { - this.pattern = this.userSettings.getSelectedPatternName(this.cosmetics); - } else { - this.pattern = null; - } - } - private onInputClick(e: Event) { e.preventDefault(); e.stopPropagation(); @@ -50,10 +38,9 @@ export class PatternInput extends LitElement { super.connectedCallback(); this._abortController = new AbortController(); this.isLoading = true; - const cosmetics = await fetchCosmetics(); - if (!this.isConnected) return; - this.cosmetics = cosmetics; - this.updateFromSettings(); + const cosmetics = await getPlayerCosmetics(); + this.selectedColor = cosmetics.color?.color ?? null; + this.pattern = cosmetics.pattern ?? null; if (!this.isConnected) return; this.isLoading = false; window.addEventListener("pattern-selected", this._onPatternSelected, { diff --git a/src/client/RewardedVideoPromo.ts b/src/client/RewardedVideoPromo.ts new file mode 100644 index 000000000..fdc2906ca --- /dev/null +++ b/src/client/RewardedVideoPromo.ts @@ -0,0 +1,179 @@ +let rewardedUnitRegistered = false; +let rewardedAdReady = false; + +// Listen for when rewarded ad becomes available +if (typeof window !== "undefined") { + window.addEventListener("rewardedAdVideoRewardReady", () => { + console.log("[RewardedVideoPromo] Rewarded ad is ready"); + rewardedAdReady = true; + }); +} + +const AD_READY_TIMEOUT_MS = 3000; + +function ensureRewardedUnitRegistered(): Promise { + console.log("[ensureRewardedUnitRegistered] Called", { + rewardedUnitRegistered, + rewardedAdReady, + hasSpaAddAds: !!window.ramp?.spaAddAds, + }); + + return new Promise((resolve, reject) => { + // Check for real SDK (not just stub from index.html) + if (!window.ramp?.spaAddAds) { + console.log( + "[ensureRewardedUnitRegistered] Rejecting: spaAddAds not available", + ); + reject(new Error("Ramp SDK not available")); + return; + } + + // If already registered and ready, resolve immediately + if (rewardedUnitRegistered && rewardedAdReady) { + console.log( + "[ensureRewardedUnitRegistered] Already registered and ready", + ); + resolve(); + return; + } + + // Register the unit if not already registered + if (!rewardedUnitRegistered) { + try { + window.ramp.spaAddAds([{ type: "rewarded_ad_video", selectorId: "" }]); + rewardedUnitRegistered = true; + console.log("[RewardedVideoPromo] Rewarded unit registered"); + } catch (e) { + reject(e); + return; + } + } + + // If ad is already ready, resolve + if (rewardedAdReady) { + console.log("[ensureRewardedUnitRegistered] Ad already ready"); + resolve(); + return; + } + + // Wait for the rewardedAdVideoRewardReady event or no-fill event + console.log("[ensureRewardedUnitRegistered] Waiting for ad to be ready..."); + let timeoutId: ReturnType | null = null; + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId); + window.removeEventListener("rewardedAdVideoRewardReady", onReady); + window.removeEventListener("rewardedVideoNoFill", onNoFill); + window.removeEventListener("rewardedAdNoFill", onNoFill); + window.removeEventListener("pwNoFillEvent", onNoFill); + }; + + const onReady = () => { + console.log("[ensureRewardedUnitRegistered] Ad is now ready"); + cleanup(); + resolve(); + }; + + const onNoFill = () => { + console.log("[ensureRewardedUnitRegistered] No fill event received"); + cleanup(); + reject(new Error("No rewarded ad available")); + }; + + timeoutId = setTimeout(() => { + cleanup(); + console.log("[ensureRewardedUnitRegistered] Timeout waiting for ad"); + reject(new Error("Ad timeout")); + }, AD_READY_TIMEOUT_MS); + + window.addEventListener("rewardedAdVideoRewardReady", onReady); + window.addEventListener("rewardedVideoNoFill", onNoFill); + window.addEventListener("rewardedAdNoFill", onNoFill); + window.addEventListener("pwNoFillEvent", onNoFill); + }); +} + +export function showRewardedAd(): Promise { + console.log("[showRewardedAd] Called", { + rewardedUnitRegistered, + }); + + return new Promise((resolve, reject) => { + console.log("[showRewardedAd] Calling ensureRewardedUnitRegistered..."); + ensureRewardedUnitRegistered() + .then(() => { + console.log("[showRewardedAd] ensureRewardedUnitRegistered resolved"); + if (!window.ramp?.manuallyCreateRewardUi) { + reject(new Error("Ramp SDK manuallyCreateRewardUi not available")); + return; + } + + // Set up event listeners before triggering the ad + const cleanup = () => { + window.removeEventListener( + "rewardedAdRewardGranted", + onRewardGranted, + ); + window.removeEventListener("rewardedAdCompleted", onCompleted); + window.removeEventListener("rewardedCloseButtonTriggered", onClosed); + window.removeEventListener("rejectAdCloseCta", onRejected); + // Destroy old unit and reset state so next ad attempt will re-register + try { + window.ramp?.destroyUnits?.("rewarded_ad_video"); + } catch (e) { + console.error("[showRewardedAd] Failed to destroy unit:", e); + } + rewardedUnitRegistered = false; + rewardedAdReady = false; + }; + + const onRewardGranted = () => { + console.log("[showRewardedAd] Reward granted"); + cleanup(); + resolve(); + }; + + const onCompleted = () => { + console.log("[showRewardedAd] Ad completed without reward"); + // Don't resolve here - wait for rewardedAdRewardGranted + }; + + const onClosed = () => { + console.log("[showRewardedAd] User closed ad early"); + cleanup(); + reject(new Error("User closed ad early")); + }; + + const onRejected = () => { + console.log("[showRewardedAd] User rejected ad"); + cleanup(); + reject(new Error("User rejected ad")); + }; + + window.addEventListener("rewardedAdRewardGranted", onRewardGranted); + window.addEventListener("rewardedAdCompleted", onCompleted); + window.addEventListener("rewardedCloseButtonTriggered", onClosed); + window.addEventListener("rejectAdCloseCta", onRejected); + + // Trigger the ad + const result = window.ramp.manuallyCreateRewardUi({ + skipConfirmation: true, + }); + + // If it returns a promise that rejects, handle that too + if (result && typeof result.then === "function") { + result.catch((error: unknown) => { + cleanup(); + reject(error); + }); + } + }) + .catch((err) => { + console.log( + "[showRewardedAd] ensureRewardedUnitRegistered rejected:", + err, + ); + reject(err); + }); + }); +} diff --git a/src/client/SinglePlayerModal.ts b/src/client/SinglePlayerModal.ts index 440d1948a..bafab8238 100644 --- a/src/client/SinglePlayerModal.ts +++ b/src/client/SinglePlayerModal.ts @@ -15,7 +15,6 @@ import { UnitType, mapCategories, } from "../core/game/Game"; -import { UserSettings } from "../core/game/UserSettings"; import { TeamCountConfig } from "../core/Schemas"; import { generateID } from "../core/Util"; import { hasLinkedAccount } from "./Api"; @@ -26,9 +25,8 @@ import "./components/Difficulties"; import "./components/FluentSlider"; import "./components/Maps"; import { modalHeader } from "./components/ui/ModalHeader"; -import { fetchCosmetics } from "./Cosmetics"; +import { getPlayerCosmetics } from "./Cosmetics"; import { crazyGamesSDK } from "./CrazyGamesSDK"; -import { FlagInput } from "./FlagInput"; import { JoinLobbyEvent } from "./Main"; import { UsernameInput } from "./UsernameInput"; import { renderUnitTypeOptions } from "./utilities/RenderUnitTypeOptions"; @@ -60,8 +58,6 @@ export class SinglePlayerModal extends BaseModal { @state() private disabledUnits: UnitType[] = []; - private userSettings: UserSettings = new UserSettings(); - connectedCallback() { super.connectedCallback(); document.addEventListener( @@ -1049,18 +1045,6 @@ export class SinglePlayerModal extends BaseModal { console.warn("Username input element not found"); } - const flagInput = document.querySelector("flag-input") as FlagInput; - if (!flagInput) { - console.warn("Flag input element not found"); - } - const cosmetics = await fetchCosmetics(); - let selectedPattern = this.userSettings.getSelectedPatternName(cosmetics); - selectedPattern ??= cosmetics - ? (this.userSettings.getDevOnlyPattern() ?? null) - : null; - - const selectedColor = this.userSettings.getSelectedColor(); - await crazyGamesSDK.requestMidgameAd(); this.dispatchEvent( @@ -1074,14 +1058,7 @@ export class SinglePlayerModal extends BaseModal { { clientID, username: usernameInput.getCurrentUsername(), - cosmetics: { - flag: - flagInput.getCurrentFlag() === "xx" - ? "" - : flagInput.getCurrentFlag(), - pattern: selectedPattern ?? undefined, - color: selectedColor ? { color: selectedColor } : undefined, - }, + cosmetics: await getPlayerCosmetics(), }, ], config: { diff --git a/src/client/TerritoryPatternsModal.ts b/src/client/TerritoryPatternsModal.ts index 86701db41..00a92e433 100644 --- a/src/client/TerritoryPatternsModal.ts +++ b/src/client/TerritoryPatternsModal.ts @@ -12,8 +12,10 @@ import "./components/PatternButton"; import { modalHeader } from "./components/ui/ModalHeader"; import { fetchCosmetics, + getPlayerCosmetics, handlePurchase, patternRelationship, + TEMP_FLARE_OFFSET, } from "./Cosmetics"; import { translateText } from "./Utils"; @@ -37,8 +39,8 @@ export class TerritoryPatternsModal extends BaseModal { private userMeResponse: UserMeResponse | false = false; - private _onPatternSelected = () => { - this.updateFromSettings(); + private _onPatternSelected = async () => { + await this.updateFromSettings(); this.refresh(); }; @@ -62,24 +64,16 @@ export class TerritoryPatternsModal extends BaseModal { window.removeEventListener("pattern-selected", this._onPatternSelected); } - private updateFromSettings() { - this.selectedPattern = - this.cosmetics !== null - ? this.userSettings.getSelectedPatternName(this.cosmetics) - : null; - this.selectedColor = this.userSettings.getSelectedColor() ?? null; + private async updateFromSettings() { + const cosmetics = await getPlayerCosmetics(); + this.selectedPattern = cosmetics.pattern ?? null; + this.selectedColor = cosmetics.color?.color ?? null; } async onUserMe(userMeResponse: UserMeResponse | false) { - if (!hasLinkedAccount(userMeResponse)) { - this.userSettings.setSelectedPatternName(undefined); - this.userSettings.setSelectedColor(undefined); - this.selectedPattern = null; - this.selectedColor = null; - } this.userMeResponse = userMeResponse; this.cosmetics = await fetchCosmetics(); - this.updateFromSettings(); + await this.updateFromSettings(); this.refresh(); } @@ -130,7 +124,7 @@ export class TerritoryPatternsModal extends BaseModal { ? [...(pattern.colorPalettes ?? []), null] : [null]; for (const colorPalette of colorPalettes) { - let rel = "owned"; + let rel: string | number = "owned"; if (pattern) { rel = patternRelationship( pattern, @@ -142,8 +136,9 @@ export class TerritoryPatternsModal extends BaseModal { if (rel === "blocked") { continue; } + const isTrial = typeof rel === "number"; if (this.showOnlyOwned) { - if (rel !== "owned") continue; + if (rel !== "owned" && !isTrial) continue; } else { // Store mode: hide owned items if (rel === "owned") continue; @@ -163,7 +158,19 @@ export class TerritoryPatternsModal extends BaseModal { .colorPalette=${this.cosmetics?.colorPalettes?.[ colorPalette?.name ?? "" ] ?? null} - .requiresPurchase=${rel === "purchasable"} + .requiresPurchase=${rel === "purchasable" || + rel === "purchasable_no_trial"} + .allowTrial=${rel === "purchasable"} + .trialCooldown=${this.userMeResponse !== false && + this.userMeResponse.player.tempFlaresCooldown} + .trialTimeRemaining=${isTrial + ? Math.max( + 0, + Math.floor( + ((rel as number) - TEMP_FLARE_OFFSET - Date.now()) / 1000, + ), + ) + : 0} .selected=${isSelected} .onSelect=${(p: PlayerPattern | null) => this.selectPattern(p)} .onPurchase=${(p: Pattern, colorPalette: ColorPalette | null) => diff --git a/src/client/components/PatternButton.ts b/src/client/components/PatternButton.ts index 6d6b94b18..e381f95ea 100644 --- a/src/client/components/PatternButton.ts +++ b/src/client/components/PatternButton.ts @@ -1,14 +1,17 @@ import { Colord } from "colord"; import { base64url } from "jose"; import { html, LitElement, TemplateResult } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { customElement, property, state } from "lit/decorators.js"; import { ColorPalette, DefaultPattern, Pattern, } from "../../core/CosmeticSchemas"; +import { UserSettings } from "../../core/game/UserSettings"; import { PatternDecoder } from "../../core/PatternDecoder"; import { PlayerPattern } from "../../core/Schemas"; +import { grantTemporaryFlare } from "../Api"; +import { showRewardedAd } from "../RewardedVideoPromo"; import { translateText } from "../Utils"; export const BUTTON_WIDTH = 150; @@ -26,16 +29,61 @@ export class PatternButton extends LitElement { @property({ type: Boolean }) requiresPurchase: boolean = false; + @property({ type: Number }) + trialTimeRemaining: number = 0; + + @property({ type: Boolean }) + allowTrial: boolean = true; + + @property({ type: Boolean }) + trialCooldown: boolean = false; + @property({ type: Function }) onSelect?: (pattern: PlayerPattern | null) => void; @property({ type: Function }) onPurchase?: (pattern: Pattern, colorPalette: ColorPalette | null) => void; + private _countdownInterval: ReturnType | null = null; + + @state() + private _adLoading: boolean = false; + createRenderRoot() { return this; } + updated(changedProperties: Map) { + if (changedProperties.has("trialTimeRemaining")) { + this.setupCountdown(); + } + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.clearCountdown(); + } + + private setupCountdown() { + this.clearCountdown(); + if (this.trialTimeRemaining > 0) { + this._countdownInterval = setInterval(() => { + this.trialTimeRemaining--; + if (this.trialTimeRemaining <= 0) { + this.trialTimeRemaining = 0; + this.clearCountdown(); + } + }, 1000); + } + } + + private clearCountdown() { + if (this._countdownInterval !== null) { + clearInterval(this._countdownInterval); + this._countdownInterval = null; + } + } + private translateCosmetic(prefix: string, patternName: string): string { const translation = translateText(`${prefix}.${patternName}`); if (translation.startsWith(prefix)) { @@ -60,6 +108,99 @@ export class PatternButton extends LitElement { } satisfies PlayerPattern); } + private async grantTrial() { + const flare = + this.colorPalette?.name === undefined + ? `pattern:${this.pattern!.name}` + : `pattern:${this.pattern!.name}:${this.colorPalette.name}`; + await grantTemporaryFlare(flare); + new UserSettings().setSelectedPatternName(flare); + alert(translateText("territory_patterns.trial_granted")); + window.location.reload(); + } + + private showSteamModal(): Promise { + return new Promise((resolve) => { + const overlay = document.createElement("div"); + overlay.className = + "fixed inset-0 bg-black/80 flex items-center justify-center z-[9999]"; + + let secondsLeft = 10; + const updateContent = () => { + overlay.innerHTML = ` +
+

Wishlist on Steam!

+

${translateText("territory_patterns.steam_wishlist_prompt")}

+ + + + + + Wishlist on Steam + + +
+ ${translateText("territory_patterns.reward_countdown", { seconds: secondsLeft.toString() })} +
+
+ `; + }; + + updateContent(); + document.body.appendChild(overlay); + + const interval = setInterval(() => { + secondsLeft--; + if (secondsLeft <= 0) { + clearInterval(interval); + overlay.remove(); + resolve(); + } else { + updateContent(); + } + }, 1000); + }); + } + + private async handleTryMe(e: Event) { + e.stopPropagation(); + if (this.pattern === null || this._adLoading) return; + + if (this.trialCooldown) { + alert(translateText("territory_patterns.trial_cooldown")); + return; + } + + console.log("[PatternButton] handleTryMe called"); + this._adLoading = true; + + try { + console.log("[PatternButton] Calling showRewardedAd..."); + await showRewardedAd(); + console.log("[PatternButton] showRewardedAd resolved"); + await this.grantTrial(); + } catch (error) { + console.error("[PatternButton] Rewarded ad failed:", error); + // Show Steam wishlist modal with countdown + await this.showSteamModal(); + await this.grantTrial(); + } finally { + this._adLoading = false; + } + } + + private formatTimeRemaining(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; + } + private handlePurchase(e: Event) { e.stopPropagation(); if (this.pattern?.product) { @@ -137,9 +278,52 @@ export class PatternButton extends LitElement { - ${this.requiresPurchase && this.pattern?.product + ${(this.requiresPurchase || this.trialTimeRemaining > 0) && + this.pattern?.product ? html` -
+
+ ${this.trialTimeRemaining > 0 + ? html` +
+ ${this.formatTimeRemaining(this.trialTimeRemaining)} + ${translateText("territory_patterns.trial_remaining")} +
+ ` + : this.allowTrial + ? html` + + ` + : null}