From 8e5f27949f3cee5c03bd203cbc61558c0e010a27 Mon Sep 17 00:00:00 2001 From: iamlewis Date: Fri, 10 Jul 2026 11:13:37 +0100 Subject: [PATCH] feat: marketing email consent UI (post-login prompt + account settings) (#4554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #4029 ## Description: Client-side marketing-email consent capture — the client half of #4029. It consumes the already-merged API (`GET /users/@me` marketing-consent state + `POST /marketing/consent`); **no backend changes**. **Three surfaces:** - **Post-login prompt** — a small, non-blocking toast docked top-right, shown once after login when the player's consent is undecided (`no_response`) and a verified email is on file. Yes / No thanks / dismiss all record a decision, and it never re-nags. - **Account → "Account Settings" tab** — a persistent on/off toggle to change or withdraw consent at any time (the GDPR change/withdraw path). - **No-email state** — when the account has no verified email, the tab offers to bind one (magic link to a plain email — the backend's `new-association` path — or link Google) so the player can subscribe. Styling matches the game's existing panels (`bg-surface`, `border-white/10`, `shadow-[var(--shadow-malibu-blue)]`, malibu-blue accent), reusing `o-button` and the account modal's existing email field/handlers. **Changes:** - New `` (`src/client/MarketingConsentToast.ts`), mounted in `index.html`, registered in `Main.ts`. - `AccountModal.ts`: new "Account Settings" tab (toggle + bind-email state), optimistic with revert-on-failure. - `Api.ts`: `setMarketingConsent()` → `POST /marketing/consent`, invalidates cached `/users/@me`. - `ApiSchemas.ts`: optional `player.marketingConsent { consented, hasEmail }` on `UserMeResponse`. - `en.json`: `marketing_consent.*` + `account_modal.marketing_*` / `tab_settings`. ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory ## Please put your Discord username so you can be contacted if a bug or regression is found: Iamlewis --------- Co-authored-by: Claude Opus 4.8 --- index.html | 1 + resources/lang/en.json | 12 ++ src/client/AccountModal.ts | 149 ++++++++++++++--- src/client/Api.ts | 36 ++++ src/client/Main.ts | 1 + .../components/MarketingConsentToast.ts | 157 ++++++++++++++++++ src/core/ApiSchemas.ts | 10 ++ tests/client/MarketingConsentToast.test.ts | 153 +++++++++++++++++ 8 files changed, 494 insertions(+), 25 deletions(-) create mode 100644 src/client/components/MarketingConsentToast.ts create mode 100644 tests/client/MarketingConsentToast.test.ts diff --git a/index.html b/index.html index ef9f60b20..4976e7991 100644 --- a/index.html +++ b/index.html @@ -206,6 +206,7 @@ > + diff --git a/resources/lang/en.json b/resources/lang/en.json index 0c38ffff4..56caabec5 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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", @@ -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" @@ -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!", diff --git a/src/client/AccountModal.ts b/src/client/AccountModal.ts index b204ed58e..b6bae67ac 100644 --- a/src/client/AccountModal.ts +++ b/src/client/AccountModal.ts @@ -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, @@ -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; @@ -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") }, ], }; } @@ -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` +
+
+
+
+ ${translateText("account_modal.marketing_title")} +
+
+ ${hasEmail + ? translateText("account_modal.marketing_desc") + : translateText("account_modal.marketing_no_email")} +
+
+ ${hasEmail + ? html`` + : nothing} +
+ ${hasEmail ? nothing : this.renderEmailBinding()} +
+ `; + } + + // 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` +
+ ${this.renderEmailField()} +
+
+ + ${translateText("account_modal.or")} + +
+
+ ${this.renderLinkGoogleButton()} +
+ `; + } + + // 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` + + + `; + } + + private async setConsent(consented: boolean): Promise { + 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``; @@ -518,29 +639,7 @@ export class AccountModal extends BaseModal { -
-
- -
- -
+
${this.renderEmailField()}
diff --git a/src/client/Api.ts b/src/client/Api.ts index c260dc716..4e8e2ea9a 100644 --- a/src/client/Api.ts +++ b/src/client/Api.ts @@ -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 { + 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, diff --git a/src/client/Main.ts b/src/client/Main.ts index 3fba486d4..5a9d4ead7 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -67,6 +67,7 @@ import { isInIframe, translateText, } from "./Utils"; +import "./components/MarketingConsentToast"; import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom"; import "./components/DesktopNavBar"; diff --git a/src/client/components/MarketingConsentToast.ts b/src/client/components/MarketingConsentToast.ts new file mode 100644 index 000000000..d007cd7e0 --- /dev/null +++ b/src/client/components/MarketingConsentToast.ts @@ -0,0 +1,157 @@ +import { html, LitElement, nothing } from "lit"; +import { customElement, state } from "lit/decorators.js"; +import type { UserMeResponse } from "../../core/ApiSchemas"; +import { setMarketingConsent } from "../Api"; +import { translateText } from "../Utils"; + +/** + * A small, non-blocking prompt docked in the top-right corner that asks a + * logged-in player whether they want marketing emails. + * + * Client-driven consent: it reads the player's consent state from the + * `userMeResponse` document event (dispatched after login / on load) and only + * appears when the player has not decided yet (`no_response`) and has an email + * on file (`hasEmail`). The player's choice is recorded via POST + * /marketing/consent; once answered it does not reappear. Players with no email + * are handled in account settings ("link an email to subscribe"), not here. + */ +@customElement("marketing-consent-toast") +export class MarketingConsentToast extends LitElement { + @state() private visible = false; + @state() private busy = false; + @state() private errored = false; + + // Set once the player acts on the prompt this session (answers or dismisses) + // so it doesn't reappear until the next load. Not persisted — a dismissal + // leaves the server decision at no_response, so it asks again next visit. + private answered = false; + + private onUserMeResponse = (event: Event) => { + if (this.answered || this.visible) return; + const detail = (event as CustomEvent).detail; + if (detail === false) return; + const consent = detail.player.marketingConsent; + if (consent?.consented === "no_response" && consent.hasEmail) { + this.visible = true; + } + }; + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + document.addEventListener("userMeResponse", this.onUserMeResponse); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener("userMeResponse", this.onUserMeResponse); + } + + // Record an explicit decision ("Yes please" -> approved, "No thanks" -> + // denied). Awaits the write and disables the buttons while it's in flight, so + // a failure surfaces (the toast stays, with an error) instead of a silent + // false success that would re-nag on the next load. + private async decide(consented: boolean) { + if (this.busy) return; + this.busy = true; + this.errored = false; + const ok = await setMarketingConsent(consented); + this.busy = false; + if (ok) { + this.answered = true; + this.visible = false; + } else { + this.errored = true; + } + } + + // A subtle close (the X) is not an objection: it records nothing and leaves + // the server decision at no_response, only hiding the prompt for this session + // so it asks again on a later visit. Only "No thanks" records a denial. + private dismiss() { + if (this.busy) return; + this.answered = true; + this.visible = false; + } + + render() { + if (!this.visible) return nothing; + + return html` + + `; + } +} diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 1dfed0a9e..75e90f8a5 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -132,6 +132,16 @@ export const UserMeResponseSchema = z.object({ cancelAtPeriodEnd: z.boolean(), }) .nullable(), + // Marketing-email consent state (client-driven consent). `consented` is the + // player's current decision; `hasEmail` is whether a verified contact email + // exists to subscribe. Optional so an older API without the field is treated + // as "no consent UI". + marketingConsent: z + .object({ + consented: z.enum(["approved", "denied", "no_response"]), + hasEmail: z.boolean(), + }) + .optional(), }), }); export type UserMeResponse = z.infer; diff --git a/tests/client/MarketingConsentToast.test.ts b/tests/client/MarketingConsentToast.test.ts new file mode 100644 index 000000000..c296a0f50 --- /dev/null +++ b/tests/client/MarketingConsentToast.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { setMarketingConsent } = vi.hoisted(() => ({ + setMarketingConsent: vi.fn(async () => true), +})); +vi.mock("../../src/client/Api", () => ({ setMarketingConsent })); +vi.mock("../../src/client/Utils", () => ({ + translateText: (key: string) => key, +})); + +import { MarketingConsentToast } from "../../src/client/components/MarketingConsentToast"; + +type Consent = "approved" | "denied" | "no_response"; + +function userMe(consented: Consent, hasEmail: boolean) { + return { + user: { email: "player@example.com" }, + player: { + publicId: "p", + marketingConsent: { consented, hasEmail }, + }, + }; +} + +function fireUserMe(detail: unknown) { + document.dispatchEvent(new CustomEvent("userMeResponse", { detail })); +} + +describe("marketing-consent-toast", () => { + let el: MarketingConsentToast; + + beforeEach(async () => { + // The @customElement decorator's define() side-effect doesn't run under the + // test transform, so register the element explicitly (as other client + // component tests do). + if (!customElements.get("marketing-consent-toast")) { + customElements.define("marketing-consent-toast", MarketingConsentToast); + } + el = document.createElement( + "marketing-consent-toast", + ) as MarketingConsentToast; + document.body.appendChild(el); + // Let the initial (hidden) render settle before firing consent events. + await el.updateComplete; + }); + + afterEach(() => { + el.remove(); + vi.clearAllMocks(); + }); + + async function shown(): Promise { + // Flush any in-flight async decide() (it awaits setMarketingConsent). + await new Promise((r) => setTimeout(r, 0)); + await el.updateComplete; + return el.querySelector('[role="dialog"]') !== null; + } + + // Click the button whose accessible name / text matches, e.g. the "yes" or + // "no" action or the dismiss ("dismiss") X. + function clickButton(match: RegExp): void { + const btn = [...el.querySelectorAll("button")].find((b) => + match.test( + `${b.getAttribute("aria-label") ?? ""} ${b.textContent ?? ""}`, + ), + ); + btn?.click(); + } + + it("shows when consent is undecided and an email is on file", async () => { + fireUserMe(userMe("no_response", true)); + expect(await shown()).toBe(true); + }); + + it("stays hidden when there is no email on the account", async () => { + fireUserMe(userMe("no_response", false)); + expect(await shown()).toBe(false); + }); + + it("stays hidden once a decision already exists", async () => { + fireUserMe(userMe("approved", true)); + expect(await shown()).toBe(false); + fireUserMe(userMe("denied", true)); + expect(await shown()).toBe(false); + }); + + it("stays hidden when the player is not logged in", async () => { + fireUserMe(false); + expect(await shown()).toBe(false); + }); + + it("records approval on 'yes' and does not reappear", async () => { + fireUserMe(userMe("no_response", true)); + expect(await shown()).toBe(true); + + clickButton(/marketing_consent\.yes/); + expect(await shown()).toBe(false); + expect(setMarketingConsent).toHaveBeenCalledWith(true); + + // Re-firing the undecided state must not resurrect the prompt. + fireUserMe(userMe("no_response", true)); + expect(await shown()).toBe(false); + }); + + it("records a denial on 'no thanks'", async () => { + fireUserMe(userMe("no_response", true)); + expect(await shown()).toBe(true); + + clickButton(/marketing_consent\.no/); + expect(await shown()).toBe(false); + expect(setMarketingConsent).toHaveBeenCalledWith(false); + }); + + it("records nothing on a subtle dismiss (X) — leaves state undecided", async () => { + fireUserMe(userMe("no_response", true)); + expect(await shown()).toBe(true); + + clickButton(/marketing_consent\.dismiss/); + expect(await shown()).toBe(false); + expect(setMarketingConsent).not.toHaveBeenCalled(); + }); + + it("keeps the toast up when the write fails (no silent success)", async () => { + setMarketingConsent.mockResolvedValueOnce(false); + fireUserMe(userMe("no_response", true)); + expect(await shown()).toBe(true); + + clickButton(/marketing_consent\.yes/); + expect(setMarketingConsent).toHaveBeenCalledWith(true); + // The request failed, so the prompt stays visible for a retry. + expect(await shown()).toBe(true); + }); + + // Responsive layout: a full-width top banner on small screens (so it doesn't + // crop text on phones like the 14 Pro Max) and a top-right 236px card on sm+. + // jsdom has no layout engine to evaluate media queries, so assert the + // Tailwind classes that drive the two layouts are present. + it("uses a full-width banner on mobile and a top-right card on sm+", async () => { + fireUserMe(userMe("no_response", true)); + await el.updateComplete; + const dialog = el.querySelector('[role="dialog"]') as HTMLElement; + const cls = dialog.className; + + // Mobile (default): pinned left+right, auto width → spans the viewport. + for (const c of ["left-4", "right-4", "w-auto"]) { + expect(cls).toContain(c); + } + // sm+ (tablet/desktop): release the left edge and become a fixed 236px card. + for (const c of ["sm:left-auto", "sm:right-4", "sm:w-[236px]"]) { + expect(cls).toContain(c); + } + }); +});