Skip to content
Merged
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@
></div>

<homepage-promos></homepage-promos>
<marketing-consent-toast></marketing-consent-toast>

<!-- Main container with responsive padding -->
<main-layout class="contents">
Expand Down
12 changes: 12 additions & 0 deletions resources/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"linked_to_google_email": "Linked to Google ({email})",
"log_out": "Log Out",
"manage_subscription": "Manage",
"marketing_desc": "Occasional news, events, and game updates. Unsubscribe anytime.",
"marketing_no_email": "Link an email to your account to subscribe to updates.",
"marketing_title": "Email updates",
"no_games": "No games played yet.",
"no_stats": "No stats available yet. Play some games to start tracking.",
"not_found": "Not Found",
Expand All @@ -55,6 +58,7 @@
"tab_account": "Account",
"tab_friends": "Friends",
"tab_games": "Games",
"tab_settings": "Settings",
"tab_stats": "Stats",
"title": "Account",
"your_subscription": "Your Subscription"
Expand Down Expand Up @@ -1042,6 +1046,14 @@
"search_results": "Search Results",
"unfavorite": "Remove from favourites"
},
"marketing_consent": {
"body": "News and updates by email. Change it anytime in your account.",
"dismiss": "Dismiss",
"error": "Couldn't save that. Please try again.",
"no": "No thanks",
"title": "Keep up with OpenFront?",
"yes": "Yes please"
},
"matchmaking_button": {
"description": "(ALPHA)",
"login_required": "Login to play ranked!",
Expand Down
149 changes: 124 additions & 25 deletions src/client/AccountModal.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { html, TemplateResult } from "lit";
import { html, nothing, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ClientEnv } from "src/client/ClientEnv";
import { PlayerStatsTree, UserMeResponse } from "../core/ApiSchemas";
import { assetUrl } from "../core/AssetUrls";
import { Cosmetics } from "../core/CosmeticSchemas";
import { fetchPlayerById, getUserMe, invalidateUserMe } from "./Api";
import {
fetchPlayerById,
getUserMe,
invalidateUserMe,
setMarketingConsent,
} from "./Api";
import {
discordLogin,
googleLogin,
Expand Down Expand Up @@ -38,6 +43,7 @@ export class AccountModal extends BaseModal {
// Set on CrazyGames when a CrazyGames user is signed in. Their identity comes
// from the SDK, not our backend user object.
@state() private crazyGamesUser: CrazyGamesUser | null = null;
@state() private consentBusy: boolean = false;

private userMeResponse: UserMeResponse | null = null;
private statsTree: PlayerStatsTree | null = null;
Expand Down Expand Up @@ -142,6 +148,7 @@ export class AccountModal extends BaseModal {
{ key: "stats", label: translateText("account_modal.tab_stats") },
{ key: "games", label: translateText("account_modal.tab_games") },
{ key: "friends", label: translateText("account_modal.tab_friends") },
{ key: "settings", label: translateText("account_modal.tab_settings") },
],
};
}
Expand Down Expand Up @@ -174,11 +181,125 @@ export class AccountModal extends BaseModal {
return this.renderGamesTab();
case "friends":
return this.renderFriendsTab();
case "settings":
return this.renderSettingsTab();
default:
return this.renderAccountTab();
}
}

// Persistent marketing-consent control (client-driven consent). Mirrors the
// post-login toast: a player can turn email updates on/off any time here, or
// — when there's no verified email on the account — is told to link one.
private renderSettingsTab(): TemplateResult {
const consent = this.userMeResponse?.player?.marketingConsent;
// The API didn't return consent state (older backend). The tab is always
// shown, but with nothing to configure it stays empty rather than showing
// a misleading "link an email" prompt.
if (!consent) return html``;
const hasEmail = consent.hasEmail;
const on = consent.consented === "approved";
return html`
<div class="bg-white/5 rounded-xl border border-white/10 p-6">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<div class="text-white font-medium">
${translateText("account_modal.marketing_title")}
</div>
<div class="text-white/50 text-sm mt-1">
${hasEmail
? translateText("account_modal.marketing_desc")
: translateText("account_modal.marketing_no_email")}
</div>
</div>
${hasEmail
? html`<button
role="switch"
aria-checked=${on ? "true" : "false"}
aria-label=${translateText("account_modal.marketing_title")}
?disabled=${this.consentBusy}
@click=${() => this.setConsent(!on)}
class="relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 disabled:opacity-60 ${on
? "bg-malibu-blue shadow-[var(--shadow-malibu-blue-pill)]"
: "bg-white/15"}"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform duration-200 ${on
? "translate-x-6"
: "translate-x-1"}"
></span>
</button>`
: nothing}
</div>
${hasEmail ? nothing : this.renderEmailBinding()}
</div>
`;
}

// No verified email on the account yet. Offer both ways to attach one:
// a magic link to a plain email (the backend associates a not-yet-registered
// email with the current session — the "new-association" path), or linking a
// Google account. Reuses the login form's email field/handlers.
private renderEmailBinding(): TemplateResult {
return html`
<div class="mt-4 space-y-3">
${this.renderEmailField()}
<div class="flex items-center gap-4 py-1">
<div class="h-px bg-white/10 flex-1"></div>
<span
class="text-[10px] uppercase tracking-widest text-white/30 font-bold"
>
${translateText("account_modal.or")}
</span>
<div class="h-px bg-white/10 flex-1"></div>
</div>
${this.renderLinkGoogleButton()}
</div>
`;
}

// Shared email input + "get magic link" button, used by both the sign-in form
// and the Account Settings bind-an-email state so their styling and handlers
// stay in sync.
private renderEmailField(): TemplateResult {
return html`
<input
type="email"
.value=${this.email}
@input=${this.handleEmailInput}
placeholder=${translateText("account_modal.email_placeholder")}
class="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10"
/>
<o-button
variant="primary"
width="block"
size="md"
translationKey="account_modal.get_magic_link"
@click=${this.handleSubmit}
></o-button>
`;
}

private async setConsent(consented: boolean): Promise<void> {
const consent = this.userMeResponse?.player?.marketingConsent;
if (!consent || this.consentBusy) return;
const previous = consent.consented;
const next = consented ? "approved" : "denied";
if (previous === next) return;

// Optimistic: reflect the new state immediately, revert if the request fails.
this.consentBusy = true;
consent.consented = next;
this.requestUpdate();

const ok = await setMarketingConsent(consented);
if (!ok) {
consent.consented = previous;
}
this.consentBusy = false;
this.requestUpdate();
}

private renderFriendsTab(): TemplateResult {
const myPublicId = this.userMeResponse?.player?.publicId ?? "";
return html`<friends-list .myPublicId=${myPublicId}></friends-list>`;
Expand Down Expand Up @@ -518,29 +639,7 @@ export class AccountModal extends BaseModal {
</div>

<!-- Email Recovery -->
<div class="space-y-3">
<div class="relative group">
<input
type="email"
id="email"
name="email"
.value="${this.email}"
@input="${this.handleEmailInput}"
class="w-full pl-4 pr-12 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-2 focus:ring-malibu-blue/50 focus:border-malibu-blue/50 transition-all font-medium hover:bg-white/10"
placeholder="${translateText(
"account_modal.email_placeholder",
)}"
required
/>
</div>
<o-button
variant="primary"
width="block"
size="md"
translationKey="account_modal.get_magic_link"
@click=${this.handleSubmit}
></o-button>
</div>
<div class="space-y-3">${this.renderEmailField()}</div>
</div>

<div class="mt-8 text-center border-t border-white/10 pt-6">
Expand Down
36 changes: 36 additions & 0 deletions src/client/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,42 @@ export function invalidateUserMe() {
__userMe = null;
}

// POST /marketing/consent — record the player's marketing-email choice
// (client-driven consent). Called by the consent toast and account settings.
// Invalidates the cached /users/@me so the new decision is reflected on the
// next read. Returns true on success.
export async function setMarketingConsent(
consented: boolean,
): Promise<boolean> {
try {
const response = await fetch(`${getApiBase()}/marketing/consent`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: await getAuthHeader(),
},
body: JSON.stringify({ consented }),
});
if (response.status === 401) {
await logOut();
return false;
}
if (!response.ok) {
console.error(
"setMarketingConsent: request failed",
response.status,
response.statusText,
);
return false;
}
invalidateUserMe();
return true;
} catch (e) {
console.error("setMarketingConsent: request failed", e);
return false;
}
}

export async function purchaseWithCurrency(
cosmeticType: "pattern" | "skin" | "flag" | "effect",
cosmeticName: string,
Expand Down
1 change: 1 addition & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
isInIframe,
translateText,
} from "./Utils";
import "./components/MarketingConsentToast";
import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom";

import "./components/DesktopNavBar";
Expand Down
Loading
Loading